Compare commits
2 Commits
focas-vers
...
focas-tier
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6ff39148b | ||
| 4a6fe7fa7e |
@@ -14,6 +14,7 @@
|
|||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj"/>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire shape for a parsed FOCAS address. Mirrors <c>FocasAddress</c> in the driver
|
||||||
|
/// package but lives in Shared so the Host (.NET 4.8) can decode without taking a
|
||||||
|
/// reference to the .NET 10 driver assembly. The Proxy serializes from its own
|
||||||
|
/// <c>FocasAddress</c>; the Host maps back to its local equivalent.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class FocasAddressDto
|
||||||
|
{
|
||||||
|
/// <summary>0 = Pmc, 1 = Parameter, 2 = Macro. Matches <c>FocasAreaKind</c> enum order.</summary>
|
||||||
|
[Key(0)] public int Kind { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PMC letter — null for Parameter / Macro.</summary>
|
||||||
|
[Key(1)] public string? PmcLetter { get; set; }
|
||||||
|
|
||||||
|
[Key(2)] public int Number { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional bit index (0-7 for PMC, 0-31 for Parameter).</summary>
|
||||||
|
[Key(3)] public int? BitIndex { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 0 = Bit, 1 = Byte, 2 = Int16, 3 = Int32, 4 = Float32, 5 = Float64, 6 = String.
|
||||||
|
/// Matches <c>FocasDataType</c> enum order so both sides can cast <c>(int)</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class FocasDataTypeCode
|
||||||
|
{
|
||||||
|
public const int Bit = 0;
|
||||||
|
public const int Byte = 1;
|
||||||
|
public const int Int16 = 2;
|
||||||
|
public const int Int32 = 3;
|
||||||
|
public const int Float32 = 4;
|
||||||
|
public const int Float64 = 5;
|
||||||
|
public const int String = 6;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Length-prefixed framing. Each IPC frame is:
|
||||||
|
/// <c>[4-byte big-endian length][1-byte message kind][MessagePack body]</c>.
|
||||||
|
/// Length is the body size only; the kind byte is not part of the prefixed length.
|
||||||
|
/// Mirrors the Galaxy Tier-C framing so operators see one wire format across hosts.
|
||||||
|
/// </summary>
|
||||||
|
public static class Framing
|
||||||
|
{
|
||||||
|
public const int LengthPrefixSize = 4;
|
||||||
|
public const int KindByteSize = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum permitted body length (16 MiB). Protects the receiver from a hostile or
|
||||||
|
/// misbehaving peer sending an oversized length prefix.
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxFrameBodyBytes = 16 * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire identifier for each contract. Values are stable — new contracts append, never
|
||||||
|
/// reuse. Ranges kept aligned with Galaxy so an operator reading a hex dump doesn't have
|
||||||
|
/// to context-switch between drivers.
|
||||||
|
/// </summary>
|
||||||
|
public enum FocasMessageKind : byte
|
||||||
|
{
|
||||||
|
Hello = 0x01,
|
||||||
|
HelloAck = 0x02,
|
||||||
|
Heartbeat = 0x03,
|
||||||
|
HeartbeatAck = 0x04,
|
||||||
|
|
||||||
|
OpenSessionRequest = 0x10,
|
||||||
|
OpenSessionResponse = 0x11,
|
||||||
|
CloseSessionRequest = 0x12,
|
||||||
|
|
||||||
|
ReadRequest = 0x30,
|
||||||
|
ReadResponse = 0x31,
|
||||||
|
WriteRequest = 0x32,
|
||||||
|
WriteResponse = 0x33,
|
||||||
|
PmcBitWriteRequest = 0x34,
|
||||||
|
PmcBitWriteResponse = 0x35,
|
||||||
|
|
||||||
|
SubscribeRequest = 0x40,
|
||||||
|
SubscribeResponse = 0x41,
|
||||||
|
UnsubscribeRequest = 0x42,
|
||||||
|
OnDataChangeNotification = 0x43,
|
||||||
|
|
||||||
|
ProbeRequest = 0x70,
|
||||||
|
ProbeResponse = 0x71,
|
||||||
|
RuntimeStatusChange = 0x72,
|
||||||
|
|
||||||
|
RecycleHostRequest = 0xF0,
|
||||||
|
RecycleStatusResponse = 0xF1,
|
||||||
|
|
||||||
|
ErrorResponse = 0xFE,
|
||||||
|
}
|
||||||
63
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Hello.cs
Normal file
63
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Hello.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// First frame of every FOCAS Proxy -> Host connection. Advertises protocol major/minor
|
||||||
|
/// and the per-process shared secret the Proxy passed to the Host at spawn time. Major
|
||||||
|
/// mismatch is fatal; minor is advisory.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class Hello
|
||||||
|
{
|
||||||
|
public const int CurrentMajor = 1;
|
||||||
|
public const int CurrentMinor = 0;
|
||||||
|
|
||||||
|
[Key(0)] public int ProtocolMajor { get; set; } = CurrentMajor;
|
||||||
|
[Key(1)] public int ProtocolMinor { get; set; } = CurrentMinor;
|
||||||
|
[Key(2)] public string PeerName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-process shared secret verified on the Host side against the value passed by the
|
||||||
|
/// supervisor at spawn time. Protects against a local attacker connecting to the pipe
|
||||||
|
/// after authenticating via the pipe ACL.
|
||||||
|
/// </summary>
|
||||||
|
[Key(3)] public string SharedSecret { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Key(4)] public string[] Features { get; set; } = System.Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class HelloAck
|
||||||
|
{
|
||||||
|
[Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor;
|
||||||
|
[Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor;
|
||||||
|
|
||||||
|
/// <summary>True if the Host accepted the hello; false + <see cref="RejectReason"/> filled if not.</summary>
|
||||||
|
[Key(2)] public bool Accepted { get; set; }
|
||||||
|
[Key(3)] public string? RejectReason { get; set; }
|
||||||
|
|
||||||
|
[Key(4)] public string HostName { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class Heartbeat
|
||||||
|
{
|
||||||
|
[Key(0)] public long MonotonicTicks { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class HeartbeatAck
|
||||||
|
{
|
||||||
|
[Key(0)] public long MonotonicTicks { get; set; }
|
||||||
|
[Key(1)] public long HostUtcUnixMs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class ErrorResponse
|
||||||
|
{
|
||||||
|
/// <summary>Stable symbolic code — e.g. <c>InvalidAddress</c>, <c>SessionNotFound</c>, <c>Fwlib32Crashed</c>.</summary>
|
||||||
|
[Key(0)] public string Code { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Key(1)] public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
47
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Probe.cs
Normal file
47
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Probe.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
/// <summary>Lightweight connectivity probe — maps to <c>cnc_rdcncstat</c> on the Host.</summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class ProbeRequest
|
||||||
|
{
|
||||||
|
[Key(0)] public long SessionId { get; set; }
|
||||||
|
[Key(1)] public int TimeoutMs { get; set; } = 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class ProbeResponse
|
||||||
|
{
|
||||||
|
[Key(0)] public bool Healthy { get; set; }
|
||||||
|
[Key(1)] public string? Error { get; set; }
|
||||||
|
[Key(2)] public long ObservedAtUtcUnixMs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-host runtime status — fan-out target when the Host observes the CNC going unreachable without the Proxy asking.</summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class RuntimeStatusChangeNotification
|
||||||
|
{
|
||||||
|
[Key(0)] public long SessionId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Running | Stopped | Unknown.</summary>
|
||||||
|
[Key(1)] public string RuntimeStatus { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Key(2)] public long ObservedAtUtcUnixMs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class RecycleHostRequest
|
||||||
|
{
|
||||||
|
/// <summary>Soft | Hard. Soft drains subscriptions first; Hard kills immediately.</summary>
|
||||||
|
[Key(0)] public string Kind { get; set; } = "Soft";
|
||||||
|
[Key(1)] public string Reason { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class RecycleStatusResponse
|
||||||
|
{
|
||||||
|
[Key(0)] public bool Accepted { get; set; }
|
||||||
|
[Key(1)] public int GraceSeconds { get; set; } = 15;
|
||||||
|
[Key(2)] public string? Error { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read one FOCAS address. Multi-read is the Proxy's responsibility — it batches
|
||||||
|
/// per-tag reads into parallel <see cref="ReadRequest"/> frames the Host services on its
|
||||||
|
/// STA thread. Keeping the IPC read single-address keeps the Host side trivial; FOCAS
|
||||||
|
/// itself has no multi-read primitive that spans area kinds.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class ReadRequest
|
||||||
|
{
|
||||||
|
[Key(0)] public long SessionId { get; set; }
|
||||||
|
[Key(1)] public FocasAddressDto Address { get; set; } = new();
|
||||||
|
[Key(2)] public int DataType { get; set; }
|
||||||
|
[Key(3)] public int TimeoutMs { get; set; } = 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class ReadResponse
|
||||||
|
{
|
||||||
|
[Key(0)] public bool Success { get; set; }
|
||||||
|
[Key(1)] public string? Error { get; set; }
|
||||||
|
|
||||||
|
/// <summary>OPC UA status code mapped by the Host via <c>FocasStatusMapper</c> — 0 = Good.</summary>
|
||||||
|
[Key(2)] public uint StatusCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>MessagePack-serialized boxed value. <c>null</c> when <see cref="Success"/> is false.</summary>
|
||||||
|
[Key(3)] public byte[]? ValueBytes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Matches <see cref="FocasDataTypeCode"/> so the Proxy knows how to deserialize.</summary>
|
||||||
|
[Key(4)] public int ValueTypeCode { get; set; }
|
||||||
|
|
||||||
|
[Key(5)] public long SourceTimestampUtcUnixMs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class WriteRequest
|
||||||
|
{
|
||||||
|
[Key(0)] public long SessionId { get; set; }
|
||||||
|
[Key(1)] public FocasAddressDto Address { get; set; } = new();
|
||||||
|
[Key(2)] public int DataType { get; set; }
|
||||||
|
[Key(3)] public byte[]? ValueBytes { get; set; }
|
||||||
|
[Key(4)] public int ValueTypeCode { get; set; }
|
||||||
|
[Key(5)] public int TimeoutMs { get; set; } = 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class WriteResponse
|
||||||
|
{
|
||||||
|
[Key(0)] public bool Success { get; set; }
|
||||||
|
[Key(1)] public string? Error { get; set; }
|
||||||
|
|
||||||
|
/// <summary>OPC UA status code — 0 = Good.</summary>
|
||||||
|
[Key(2)] public uint StatusCode { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PMC bit read-modify-write. Handled as a first-class operation (not two separate
|
||||||
|
/// read+write round-trips) so the critical section stays on the Host — serializing
|
||||||
|
/// concurrent bit writers to the same parent byte is Host-side via
|
||||||
|
/// <c>SemaphoreSlim</c> keyed on <c>(PmcLetter, Number)</c>. Mirrors the in-process
|
||||||
|
/// pattern from <c>FocasPmcBitRmw</c>.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class PmcBitWriteRequest
|
||||||
|
{
|
||||||
|
[Key(0)] public long SessionId { get; set; }
|
||||||
|
[Key(1)] public FocasAddressDto Address { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>The bit index to set/clear. 0-7.</summary>
|
||||||
|
[Key(2)] public int BitIndex { get; set; }
|
||||||
|
|
||||||
|
[Key(3)] public bool Value { get; set; }
|
||||||
|
[Key(4)] public int TimeoutMs { get; set; } = 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class PmcBitWriteResponse
|
||||||
|
{
|
||||||
|
[Key(0)] public bool Success { get; set; }
|
||||||
|
[Key(1)] public string? Error { get; set; }
|
||||||
|
[Key(2)] public uint StatusCode { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Open a FOCAS session against the CNC at <see cref="HostAddress"/>. One session per
|
||||||
|
/// configured device. The Host owns the Fwlib32 handle; the Proxy tracks only the
|
||||||
|
/// opaque <see cref="OpenSessionResponse.SessionId"/> returned on success.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class OpenSessionRequest
|
||||||
|
{
|
||||||
|
[Key(0)] public string HostAddress { get; set; } = string.Empty;
|
||||||
|
[Key(1)] public int TimeoutMs { get; set; } = 2000;
|
||||||
|
[Key(2)] public int CncSeries { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class OpenSessionResponse
|
||||||
|
{
|
||||||
|
[Key(0)] public bool Success { get; set; }
|
||||||
|
[Key(1)] public long SessionId { get; set; }
|
||||||
|
[Key(2)] public string? Error { get; set; }
|
||||||
|
[Key(3)] public string? ErrorCode { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class CloseSessionRequest
|
||||||
|
{
|
||||||
|
[Key(0)] public long SessionId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribe the Host to polling a set of tags on behalf of the Proxy. FOCAS is
|
||||||
|
/// poll-only — there are no CNC-initiated callbacks — so the Host runs the poll loop and
|
||||||
|
/// pushes <see cref="OnDataChangeNotification"/> frames whenever a value differs from
|
||||||
|
/// the last observation. Delta-only + per-group interval keeps the wire quiet.
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class SubscribeRequest
|
||||||
|
{
|
||||||
|
[Key(0)] public long SessionId { get; set; }
|
||||||
|
[Key(1)] public long SubscriptionId { get; set; }
|
||||||
|
[Key(2)] public int IntervalMs { get; set; } = 1000;
|
||||||
|
[Key(3)] public SubscribeItem[] Items { get; set; } = System.Array.Empty<SubscribeItem>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class SubscribeItem
|
||||||
|
{
|
||||||
|
/// <summary>Opaque correlation id the Proxy uses to route notifications back to the right OPC UA MonitoredItem.</summary>
|
||||||
|
[Key(0)] public long MonitoredItemId { get; set; }
|
||||||
|
|
||||||
|
[Key(1)] public FocasAddressDto Address { get; set; } = new();
|
||||||
|
[Key(2)] public int DataType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class SubscribeResponse
|
||||||
|
{
|
||||||
|
[Key(0)] public bool Success { get; set; }
|
||||||
|
[Key(1)] public string? Error { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Items the Host refused (address mismatch, unsupported type). Empty on full success.</summary>
|
||||||
|
[Key(2)] public long[] RejectedMonitoredItemIds { get; set; } = System.Array.Empty<long>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class UnsubscribeRequest
|
||||||
|
{
|
||||||
|
[Key(0)] public long SubscriptionId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class OnDataChangeNotification
|
||||||
|
{
|
||||||
|
[Key(0)] public long SubscriptionId { get; set; }
|
||||||
|
[Key(1)] public DataChange[] Changes { get; set; } = System.Array.Empty<DataChange>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MessagePackObject]
|
||||||
|
public sealed class DataChange
|
||||||
|
{
|
||||||
|
[Key(0)] public long MonitoredItemId { get; set; }
|
||||||
|
[Key(1)] public uint StatusCode { get; set; }
|
||||||
|
[Key(2)] public byte[]? ValueBytes { get; set; }
|
||||||
|
[Key(3)] public int ValueTypeCode { get; set; }
|
||||||
|
[Key(4)] public long SourceTimestampUtcUnixMs { get; set; }
|
||||||
|
}
|
||||||
67
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameReader.cs
Normal file
67
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameReader.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MessagePack;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call
|
||||||
|
/// <see cref="ReadFrameAsync"/> from multiple threads against the same instance.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FrameReader : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Stream _stream;
|
||||||
|
private readonly bool _leaveOpen;
|
||||||
|
|
||||||
|
public FrameReader(Stream stream, bool leaveOpen = false)
|
||||||
|
{
|
||||||
|
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||||
|
_leaveOpen = leaveOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(FocasMessageKind Kind, byte[] Body)?> ReadFrameAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var lengthPrefix = new byte[Framing.LengthPrefixSize];
|
||||||
|
if (!await ReadExactAsync(lengthPrefix, ct).ConfigureAwait(false))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var length = (lengthPrefix[0] << 24) | (lengthPrefix[1] << 16) | (lengthPrefix[2] << 8) | lengthPrefix[3];
|
||||||
|
if (length < 0 || length > Framing.MaxFrameBodyBytes)
|
||||||
|
throw new InvalidDataException($"IPC frame length {length} out of range.");
|
||||||
|
|
||||||
|
var kindByte = _stream.ReadByte();
|
||||||
|
if (kindByte < 0) throw new EndOfStreamException("EOF after length prefix, before kind byte.");
|
||||||
|
|
||||||
|
var body = new byte[length];
|
||||||
|
if (!await ReadExactAsync(body, ct).ConfigureAwait(false))
|
||||||
|
throw new EndOfStreamException("EOF mid-frame.");
|
||||||
|
|
||||||
|
return ((FocasMessageKind)(byte)kindByte, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static T Deserialize<T>(byte[] body) => MessagePackSerializer.Deserialize<T>(body);
|
||||||
|
|
||||||
|
private async Task<bool> ReadExactAsync(byte[] buffer, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var offset = 0;
|
||||||
|
while (offset < buffer.Length)
|
||||||
|
{
|
||||||
|
var read = await _stream.ReadAsync(buffer, offset, buffer.Length - offset, ct).ConfigureAwait(false);
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
if (offset == 0) return false;
|
||||||
|
throw new EndOfStreamException($"Stream ended after reading {offset} of {buffer.Length} bytes.");
|
||||||
|
}
|
||||||
|
offset += read;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_leaveOpen) _stream.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameWriter.cs
Normal file
56
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameWriter.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MessagePack;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via
|
||||||
|
/// <see cref="SemaphoreSlim"/> — multiple producers (e.g. heartbeat + data-plane sharing a
|
||||||
|
/// stream) get serialized writes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FrameWriter : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Stream _stream;
|
||||||
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
private readonly bool _leaveOpen;
|
||||||
|
|
||||||
|
public FrameWriter(Stream stream, bool leaveOpen = false)
|
||||||
|
{
|
||||||
|
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||||
|
_leaveOpen = leaveOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WriteAsync<T>(FocasMessageKind kind, T message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var body = MessagePackSerializer.Serialize(message, cancellationToken: ct);
|
||||||
|
if (body.Length > Framing.MaxFrameBodyBytes)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"IPC frame body {body.Length} exceeds {Framing.MaxFrameBodyBytes} byte cap.");
|
||||||
|
|
||||||
|
var lengthPrefix = new byte[Framing.LengthPrefixSize];
|
||||||
|
lengthPrefix[0] = (byte)((body.Length >> 24) & 0xFF);
|
||||||
|
lengthPrefix[1] = (byte)((body.Length >> 16) & 0xFF);
|
||||||
|
lengthPrefix[2] = (byte)((body.Length >> 8) & 0xFF);
|
||||||
|
lengthPrefix[3] = (byte)( body.Length & 0xFF);
|
||||||
|
|
||||||
|
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _stream.WriteAsync(lengthPrefix, 0, lengthPrefix.Length, ct).ConfigureAwait(false);
|
||||||
|
_stream.WriteByte((byte)kind);
|
||||||
|
await _stream.WriteAsync(body, 0, body.Length, ct).ConfigureAwait(false);
|
||||||
|
await _stream.FlushAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally { _gate.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_gate.Dispose();
|
||||||
|
if (!_leaveOpen) _stream.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- MessagePack for IPC. Netstandard 2.0 consumable by both .NET 10 (Proxy) + .NET 4.8 (Host). -->
|
||||||
|
<PackageReference Include="MessagePack" Version="2.5.187"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
using MessagePack;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MessagePack round-trip coverage for every FOCAS IPC contract. Ensures
|
||||||
|
/// <c>[Key]</c>-tagged fields survive serialize -> deserialize without loss so the
|
||||||
|
/// wire format stays stable across Proxy (.NET 10) and Host (.NET 4.8) processes.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ContractRoundTripTests
|
||||||
|
{
|
||||||
|
private static T RoundTrip<T>(T value)
|
||||||
|
{
|
||||||
|
var bytes = MessagePackSerializer.Serialize(value);
|
||||||
|
return MessagePackSerializer.Deserialize<T>(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hello_round_trips()
|
||||||
|
{
|
||||||
|
var original = new Hello
|
||||||
|
{
|
||||||
|
ProtocolMajor = 1,
|
||||||
|
ProtocolMinor = 2,
|
||||||
|
PeerName = "OtOpcUa.Server",
|
||||||
|
SharedSecret = "abc-123",
|
||||||
|
Features = ["bulk-read", "pmc-rmw"],
|
||||||
|
};
|
||||||
|
var decoded = RoundTrip(original);
|
||||||
|
decoded.ProtocolMajor.ShouldBe(1);
|
||||||
|
decoded.ProtocolMinor.ShouldBe(2);
|
||||||
|
decoded.PeerName.ShouldBe("OtOpcUa.Server");
|
||||||
|
decoded.SharedSecret.ShouldBe("abc-123");
|
||||||
|
decoded.Features.ShouldBe(["bulk-read", "pmc-rmw"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HelloAck_rejected_carries_reason()
|
||||||
|
{
|
||||||
|
var original = new HelloAck { Accepted = false, RejectReason = "bad secret" };
|
||||||
|
var decoded = RoundTrip(original);
|
||||||
|
decoded.Accepted.ShouldBeFalse();
|
||||||
|
decoded.RejectReason.ShouldBe("bad secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Heartbeat_and_ack_preserve_ticks()
|
||||||
|
{
|
||||||
|
var hb = RoundTrip(new Heartbeat { MonotonicTicks = 987654321 });
|
||||||
|
hb.MonotonicTicks.ShouldBe(987654321);
|
||||||
|
|
||||||
|
var ack = RoundTrip(new HeartbeatAck { MonotonicTicks = 987654321, HostUtcUnixMs = 1_700_000_000_000 });
|
||||||
|
ack.MonotonicTicks.ShouldBe(987654321);
|
||||||
|
ack.HostUtcUnixMs.ShouldBe(1_700_000_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ErrorResponse_preserves_code_and_message()
|
||||||
|
{
|
||||||
|
var decoded = RoundTrip(new ErrorResponse { Code = "Fwlib32Crashed", Message = "EW_UNEXPECTED" });
|
||||||
|
decoded.Code.ShouldBe("Fwlib32Crashed");
|
||||||
|
decoded.Message.ShouldBe("EW_UNEXPECTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenSessionRequest_preserves_series_and_timeout()
|
||||||
|
{
|
||||||
|
var decoded = RoundTrip(new OpenSessionRequest
|
||||||
|
{
|
||||||
|
HostAddress = "192.168.1.50:8193",
|
||||||
|
TimeoutMs = 3500,
|
||||||
|
CncSeries = 5,
|
||||||
|
});
|
||||||
|
decoded.HostAddress.ShouldBe("192.168.1.50:8193");
|
||||||
|
decoded.TimeoutMs.ShouldBe(3500);
|
||||||
|
decoded.CncSeries.ShouldBe(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenSessionResponse_failure_carries_error_code()
|
||||||
|
{
|
||||||
|
var decoded = RoundTrip(new OpenSessionResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
SessionId = 0,
|
||||||
|
Error = "unreachable",
|
||||||
|
ErrorCode = "EW_SOCKET",
|
||||||
|
});
|
||||||
|
decoded.Success.ShouldBeFalse();
|
||||||
|
decoded.Error.ShouldBe("unreachable");
|
||||||
|
decoded.ErrorCode.ShouldBe("EW_SOCKET");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FocasAddressDto_carries_pmc_with_bit_index()
|
||||||
|
{
|
||||||
|
var decoded = RoundTrip(new FocasAddressDto
|
||||||
|
{
|
||||||
|
Kind = 0,
|
||||||
|
PmcLetter = "R",
|
||||||
|
Number = 100,
|
||||||
|
BitIndex = 3,
|
||||||
|
});
|
||||||
|
decoded.Kind.ShouldBe(0);
|
||||||
|
decoded.PmcLetter.ShouldBe("R");
|
||||||
|
decoded.Number.ShouldBe(100);
|
||||||
|
decoded.BitIndex.ShouldBe(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FocasAddressDto_macro_omits_letter_and_bit()
|
||||||
|
{
|
||||||
|
var decoded = RoundTrip(new FocasAddressDto { Kind = 2, Number = 500 });
|
||||||
|
decoded.Kind.ShouldBe(2);
|
||||||
|
decoded.PmcLetter.ShouldBeNull();
|
||||||
|
decoded.Number.ShouldBe(500);
|
||||||
|
decoded.BitIndex.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReadRequest_and_response_round_trip()
|
||||||
|
{
|
||||||
|
var req = RoundTrip(new ReadRequest
|
||||||
|
{
|
||||||
|
SessionId = 42,
|
||||||
|
Address = new FocasAddressDto { Kind = 1, Number = 1815 },
|
||||||
|
DataType = FocasDataTypeCode.Int32,
|
||||||
|
TimeoutMs = 1500,
|
||||||
|
});
|
||||||
|
req.SessionId.ShouldBe(42);
|
||||||
|
req.Address.Number.ShouldBe(1815);
|
||||||
|
req.DataType.ShouldBe(FocasDataTypeCode.Int32);
|
||||||
|
|
||||||
|
var resp = RoundTrip(new ReadResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
StatusCode = 0,
|
||||||
|
ValueBytes = MessagePackSerializer.Serialize((int)12345),
|
||||||
|
ValueTypeCode = FocasDataTypeCode.Int32,
|
||||||
|
SourceTimestampUtcUnixMs = 1_700_000_000_000,
|
||||||
|
});
|
||||||
|
resp.Success.ShouldBeTrue();
|
||||||
|
resp.StatusCode.ShouldBe(0u);
|
||||||
|
MessagePackSerializer.Deserialize<int>(resp.ValueBytes!).ShouldBe(12345);
|
||||||
|
resp.ValueTypeCode.ShouldBe(FocasDataTypeCode.Int32);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WriteRequest_and_response_round_trip()
|
||||||
|
{
|
||||||
|
var req = RoundTrip(new WriteRequest
|
||||||
|
{
|
||||||
|
SessionId = 1,
|
||||||
|
Address = new FocasAddressDto { Kind = 2, Number = 500 },
|
||||||
|
DataType = FocasDataTypeCode.Float64,
|
||||||
|
ValueBytes = MessagePackSerializer.Serialize(3.14159),
|
||||||
|
ValueTypeCode = FocasDataTypeCode.Float64,
|
||||||
|
});
|
||||||
|
MessagePackSerializer.Deserialize<double>(req.ValueBytes!).ShouldBe(3.14159);
|
||||||
|
|
||||||
|
var resp = RoundTrip(new WriteResponse { Success = true, StatusCode = 0 });
|
||||||
|
resp.Success.ShouldBeTrue();
|
||||||
|
resp.StatusCode.ShouldBe(0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PmcBitWriteRequest_preserves_bit_and_value()
|
||||||
|
{
|
||||||
|
var req = RoundTrip(new PmcBitWriteRequest
|
||||||
|
{
|
||||||
|
SessionId = 7,
|
||||||
|
Address = new FocasAddressDto { Kind = 0, PmcLetter = "Y", Number = 12 },
|
||||||
|
BitIndex = 5,
|
||||||
|
Value = true,
|
||||||
|
});
|
||||||
|
req.BitIndex.ShouldBe(5);
|
||||||
|
req.Value.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubscribeRequest_round_trips_multiple_items()
|
||||||
|
{
|
||||||
|
var original = new SubscribeRequest
|
||||||
|
{
|
||||||
|
SessionId = 1,
|
||||||
|
SubscriptionId = 100,
|
||||||
|
IntervalMs = 250,
|
||||||
|
Items =
|
||||||
|
[
|
||||||
|
new() { MonitoredItemId = 1, Address = new() { Kind = 0, PmcLetter = "R", Number = 100 }, DataType = FocasDataTypeCode.Bit },
|
||||||
|
new() { MonitoredItemId = 2, Address = new() { Kind = 2, Number = 500 }, DataType = FocasDataTypeCode.Float64 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var decoded = RoundTrip(original);
|
||||||
|
decoded.Items.Length.ShouldBe(2);
|
||||||
|
decoded.Items[0].MonitoredItemId.ShouldBe(1);
|
||||||
|
decoded.Items[0].Address.PmcLetter.ShouldBe("R");
|
||||||
|
decoded.Items[1].DataType.ShouldBe(FocasDataTypeCode.Float64);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubscribeResponse_rejected_items_survive()
|
||||||
|
{
|
||||||
|
var decoded = RoundTrip(new SubscribeResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
RejectedMonitoredItemIds = [2, 7],
|
||||||
|
});
|
||||||
|
decoded.RejectedMonitoredItemIds.ShouldBe([2, 7]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnsubscribeRequest_round_trips()
|
||||||
|
{
|
||||||
|
var decoded = RoundTrip(new UnsubscribeRequest { SubscriptionId = 42 });
|
||||||
|
decoded.SubscriptionId.ShouldBe(42);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OnDataChangeNotification_round_trips()
|
||||||
|
{
|
||||||
|
var original = new OnDataChangeNotification
|
||||||
|
{
|
||||||
|
SubscriptionId = 100,
|
||||||
|
Changes =
|
||||||
|
[
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
MonitoredItemId = 1,
|
||||||
|
StatusCode = 0,
|
||||||
|
ValueBytes = MessagePackSerializer.Serialize(true),
|
||||||
|
ValueTypeCode = FocasDataTypeCode.Bit,
|
||||||
|
SourceTimestampUtcUnixMs = 1_700_000_000_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var decoded = RoundTrip(original);
|
||||||
|
decoded.Changes.Length.ShouldBe(1);
|
||||||
|
MessagePackSerializer.Deserialize<bool>(decoded.Changes[0].ValueBytes!).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProbeRequest_and_response_round_trip()
|
||||||
|
{
|
||||||
|
var req = RoundTrip(new ProbeRequest { SessionId = 1, TimeoutMs = 500 });
|
||||||
|
req.TimeoutMs.ShouldBe(500);
|
||||||
|
|
||||||
|
var resp = RoundTrip(new ProbeResponse { Healthy = true, ObservedAtUtcUnixMs = 1_700_000_000_000 });
|
||||||
|
resp.Healthy.ShouldBeTrue();
|
||||||
|
resp.ObservedAtUtcUnixMs.ShouldBe(1_700_000_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RuntimeStatusChangeNotification_round_trips()
|
||||||
|
{
|
||||||
|
var decoded = RoundTrip(new RuntimeStatusChangeNotification
|
||||||
|
{
|
||||||
|
SessionId = 5,
|
||||||
|
RuntimeStatus = "Stopped",
|
||||||
|
ObservedAtUtcUnixMs = 1_700_000_000_000,
|
||||||
|
});
|
||||||
|
decoded.RuntimeStatus.ShouldBe("Stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecycleHostRequest_and_response_round_trip()
|
||||||
|
{
|
||||||
|
var req = RoundTrip(new RecycleHostRequest { Kind = "Hard", Reason = "wedge-detected" });
|
||||||
|
req.Kind.ShouldBe("Hard");
|
||||||
|
req.Reason.ShouldBe("wedge-detected");
|
||||||
|
|
||||||
|
var resp = RoundTrip(new RecycleStatusResponse { Accepted = true, GraceSeconds = 20 });
|
||||||
|
resp.Accepted.ShouldBeTrue();
|
||||||
|
resp.GraceSeconds.ShouldBe(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using System.IO;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FramingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task FrameWriter_round_trips_single_frame_through_FrameReader()
|
||||||
|
{
|
||||||
|
var buffer = new MemoryStream();
|
||||||
|
using (var writer = new FrameWriter(buffer, leaveOpen: true))
|
||||||
|
{
|
||||||
|
await writer.WriteAsync(FocasMessageKind.Hello,
|
||||||
|
new Hello { PeerName = "proxy", SharedSecret = "s3cr3t" }, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.Position = 0;
|
||||||
|
using var reader = new FrameReader(buffer, leaveOpen: true);
|
||||||
|
var frame = await reader.ReadFrameAsync(TestContext.Current.CancellationToken);
|
||||||
|
frame.ShouldNotBeNull();
|
||||||
|
frame!.Value.Kind.ShouldBe(FocasMessageKind.Hello);
|
||||||
|
var hello = FrameReader.Deserialize<Hello>(frame.Value.Body);
|
||||||
|
hello.PeerName.ShouldBe("proxy");
|
||||||
|
hello.SharedSecret.ShouldBe("s3cr3t");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FrameReader_returns_null_on_clean_EOF_at_frame_boundary()
|
||||||
|
{
|
||||||
|
using var empty = new MemoryStream();
|
||||||
|
using var reader = new FrameReader(empty, leaveOpen: true);
|
||||||
|
var frame = await reader.ReadFrameAsync(TestContext.Current.CancellationToken);
|
||||||
|
frame.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FrameReader_throws_on_oversized_length_prefix()
|
||||||
|
{
|
||||||
|
var hostile = new byte[] { 0x7F, 0xFF, 0xFF, 0xFF, 0x01 }; // length > 16 MiB
|
||||||
|
using var stream = new MemoryStream(hostile);
|
||||||
|
using var reader = new FrameReader(stream, leaveOpen: true);
|
||||||
|
await Should.ThrowAsync<InvalidDataException>(async () =>
|
||||||
|
await reader.ReadFrameAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FrameReader_throws_on_mid_frame_eof()
|
||||||
|
{
|
||||||
|
var buffer = new MemoryStream();
|
||||||
|
using (var writer = new FrameWriter(buffer, leaveOpen: true))
|
||||||
|
{
|
||||||
|
await writer.WriteAsync(FocasMessageKind.Hello, new Hello { PeerName = "x" },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
// Truncate so body is incomplete.
|
||||||
|
var truncated = buffer.ToArray()[..(buffer.ToArray().Length - 2)];
|
||||||
|
using var partial = new MemoryStream(truncated);
|
||||||
|
using var reader = new FrameReader(partial, leaveOpen: true);
|
||||||
|
await Should.ThrowAsync<EndOfStreamException>(async () =>
|
||||||
|
await reader.ReadFrameAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FrameWriter_serializes_concurrent_writes()
|
||||||
|
{
|
||||||
|
var buffer = new MemoryStream();
|
||||||
|
using var writer = new FrameWriter(buffer, leaveOpen: true);
|
||||||
|
|
||||||
|
var tasks = Enumerable.Range(0, 20).Select(i => writer.WriteAsync(
|
||||||
|
FocasMessageKind.Heartbeat,
|
||||||
|
new Heartbeat { MonotonicTicks = i },
|
||||||
|
TestContext.Current.CancellationToken)).ToArray();
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
buffer.Position = 0;
|
||||||
|
using var reader = new FrameReader(buffer, leaveOpen: true);
|
||||||
|
var seen = new List<long>();
|
||||||
|
while (await reader.ReadFrameAsync(TestContext.Current.CancellationToken) is { } frame)
|
||||||
|
{
|
||||||
|
frame.Kind.ShouldBe(FocasMessageKind.Heartbeat);
|
||||||
|
seen.Add(FrameReader.Deserialize<Heartbeat>(frame.Body).MonotonicTicks);
|
||||||
|
}
|
||||||
|
seen.Count.ShouldBe(20);
|
||||||
|
seen.OrderBy(x => x).ShouldBe(Enumerable.Range(0, 20).Select(x => (long)x));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MessageKind_values_are_stable()
|
||||||
|
{
|
||||||
|
// Guardrail — if someone reorders/renumbers, the wire format breaks for deployed peers.
|
||||||
|
((byte)FocasMessageKind.Hello).ShouldBe((byte)0x01);
|
||||||
|
((byte)FocasMessageKind.Heartbeat).ShouldBe((byte)0x03);
|
||||||
|
((byte)FocasMessageKind.OpenSessionRequest).ShouldBe((byte)0x10);
|
||||||
|
((byte)FocasMessageKind.ReadRequest).ShouldBe((byte)0x30);
|
||||||
|
((byte)FocasMessageKind.WriteRequest).ShouldBe((byte)0x32);
|
||||||
|
((byte)FocasMessageKind.PmcBitWriteRequest).ShouldBe((byte)0x34);
|
||||||
|
((byte)FocasMessageKind.SubscribeRequest).ShouldBe((byte)0x40);
|
||||||
|
((byte)FocasMessageKind.OnDataChangeNotification).ShouldBe((byte)0x43);
|
||||||
|
((byte)FocasMessageKind.ProbeRequest).ShouldBe((byte)0x70);
|
||||||
|
((byte)FocasMessageKind.ErrorResponse).ShouldBe((byte)0xFE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user