diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index cc7bc01..13dbf2f 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -14,6 +14,7 @@ + @@ -41,6 +42,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Addresses.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Addresses.cs new file mode 100644 index 0000000..ca443a1 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Addresses.cs @@ -0,0 +1,39 @@ +using MessagePack; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; + +/// +/// Wire shape for a parsed FOCAS address. Mirrors FocasAddress 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 +/// FocasAddress; the Host maps back to its local equivalent. +/// +[MessagePackObject] +public sealed class FocasAddressDto +{ + /// 0 = Pmc, 1 = Parameter, 2 = Macro. Matches FocasAreaKind enum order. + [Key(0)] public int Kind { get; set; } + + /// PMC letter — null for Parameter / Macro. + [Key(1)] public string? PmcLetter { get; set; } + + [Key(2)] public int Number { get; set; } + + /// Optional bit index (0-7 for PMC, 0-31 for Parameter). + [Key(3)] public int? BitIndex { get; set; } +} + +/// +/// 0 = Bit, 1 = Byte, 2 = Int16, 3 = Int32, 4 = Float32, 5 = Float64, 6 = String. +/// Matches FocasDataType enum order so both sides can cast (int). +/// +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; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Framing.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Framing.cs new file mode 100644 index 0000000..fe2b979 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Framing.cs @@ -0,0 +1,57 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; + +/// +/// Length-prefixed framing. Each IPC frame is: +/// [4-byte big-endian length][1-byte message kind][MessagePack body]. +/// 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. +/// +public static class Framing +{ + public const int LengthPrefixSize = 4; + public const int KindByteSize = 1; + + /// + /// Maximum permitted body length (16 MiB). Protects the receiver from a hostile or + /// misbehaving peer sending an oversized length prefix. + /// + public const int MaxFrameBodyBytes = 16 * 1024 * 1024; +} + +/// +/// 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. +/// +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, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Hello.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Hello.cs new file mode 100644 index 0000000..716bb1a --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Hello.cs @@ -0,0 +1,63 @@ +using MessagePack; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; + +/// +/// 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. +/// +[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; + + /// + /// 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. + /// + [Key(3)] public string SharedSecret { get; set; } = string.Empty; + + [Key(4)] public string[] Features { get; set; } = System.Array.Empty(); +} + +[MessagePackObject] +public sealed class HelloAck +{ + [Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor; + [Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor; + + /// True if the Host accepted the hello; false + filled if not. + [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 +{ + /// Stable symbolic code — e.g. InvalidAddress, SessionNotFound, Fwlib32Crashed. + [Key(0)] public string Code { get; set; } = string.Empty; + + [Key(1)] public string Message { get; set; } = string.Empty; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Probe.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Probe.cs new file mode 100644 index 0000000..151f9d7 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Probe.cs @@ -0,0 +1,47 @@ +using MessagePack; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; + +/// Lightweight connectivity probe — maps to cnc_rdcncstat on the Host. +[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; } +} + +/// Per-host runtime status — fan-out target when the Host observes the CNC going unreachable without the Proxy asking. +[MessagePackObject] +public sealed class RuntimeStatusChangeNotification +{ + [Key(0)] public long SessionId { get; set; } + + /// Running | Stopped | Unknown. + [Key(1)] public string RuntimeStatus { get; set; } = string.Empty; + + [Key(2)] public long ObservedAtUtcUnixMs { get; set; } +} + +[MessagePackObject] +public sealed class RecycleHostRequest +{ + /// Soft | Hard. Soft drains subscriptions first; Hard kills immediately. + [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; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/ReadWrite.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/ReadWrite.cs new file mode 100644 index 0000000..3f3e7ae --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/ReadWrite.cs @@ -0,0 +1,85 @@ +using MessagePack; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; + +/// +/// Read one FOCAS address. Multi-read is the Proxy's responsibility — it batches +/// per-tag reads into parallel 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. +/// +[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; } + + /// OPC UA status code mapped by the Host via FocasStatusMapper — 0 = Good. + [Key(2)] public uint StatusCode { get; set; } + + /// MessagePack-serialized boxed value. null when is false. + [Key(3)] public byte[]? ValueBytes { get; set; } + + /// Matches so the Proxy knows how to deserialize. + [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; } + + /// OPC UA status code — 0 = Good. + [Key(2)] public uint StatusCode { get; set; } +} + +/// +/// 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 +/// SemaphoreSlim keyed on (PmcLetter, Number). Mirrors the in-process +/// pattern from FocasPmcBitRmw. +/// +[MessagePackObject] +public sealed class PmcBitWriteRequest +{ + [Key(0)] public long SessionId { get; set; } + [Key(1)] public FocasAddressDto Address { get; set; } = new(); + + /// The bit index to set/clear. 0-7. + [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; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Session.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Session.cs new file mode 100644 index 0000000..fad9126 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Session.cs @@ -0,0 +1,31 @@ +using MessagePack; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; + +/// +/// Open a FOCAS session against the CNC at . One session per +/// configured device. The Host owns the Fwlib32 handle; the Proxy tracks only the +/// opaque returned on success. +/// +[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; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Subscriptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Subscriptions.cs new file mode 100644 index 0000000..9417d2b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Subscriptions.cs @@ -0,0 +1,61 @@ +using MessagePack; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; + +/// +/// 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 frames whenever a value differs from +/// the last observation. Delta-only + per-group interval keeps the wire quiet. +/// +[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(); +} + +[MessagePackObject] +public sealed class SubscribeItem +{ + /// Opaque correlation id the Proxy uses to route notifications back to the right OPC UA MonitoredItem. + [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; } + + /// Items the Host refused (address mismatch, unsupported type). Empty on full success. + [Key(2)] public long[] RejectedMonitoredItemIds { get; set; } = System.Array.Empty(); +} + +[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(); +} + +[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; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameReader.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameReader.cs new file mode 100644 index 0000000..7cb142d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameReader.cs @@ -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; + +/// +/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call +/// from multiple threads against the same instance. +/// +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(byte[] body) => MessagePackSerializer.Deserialize(body); + + private async Task 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(); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameWriter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameWriter.cs new file mode 100644 index 0000000..76b0b19 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameWriter.cs @@ -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; + +/// +/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via +/// — multiple producers (e.g. heartbeat + data-plane sharing a +/// stream) get serialized writes. +/// +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(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(); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj new file mode 100644 index 0000000..a154f19 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj @@ -0,0 +1,23 @@ + + + + netstandard2.0 + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ContractRoundTripTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ContractRoundTripTests.cs new file mode 100644 index 0000000..bbe85d6 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ContractRoundTripTests.cs @@ -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; + +/// +/// MessagePack round-trip coverage for every FOCAS IPC contract. Ensures +/// [Key]-tagged fields survive serialize -> deserialize without loss so the +/// wire format stays stable across Proxy (.NET 10) and Host (.NET 4.8) processes. +/// +[Trait("Category", "Unit")] +public sealed class ContractRoundTripTests +{ + private static T RoundTrip(T value) + { + var bytes = MessagePackSerializer.Serialize(value); + return MessagePackSerializer.Deserialize(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(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(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(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); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/FramingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/FramingTests.cs new file mode 100644 index 0000000..66778da --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/FramingTests.cs @@ -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(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(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(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(); + while (await reader.ReadFrameAsync(TestContext.Current.CancellationToken) is { } frame) + { + frame.Kind.ShouldBe(FocasMessageKind.Heartbeat); + seen.Add(FrameReader.Deserialize(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); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests.csproj new file mode 100644 index 0000000..88c368e --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + +