FOCAS Tier-C PR A — Driver.FOCAS.Shared MessagePack IPC contracts. First PR of the 5-PR #220 split (isolation plan at docs/v2/implementation/focas-isolation-plan.md). Adds a new netstandard2.0 project consumable by both the .NET 10 Proxy and the future .NET 4.8 x86 Host, carrying every wire DTO the Proxy <-> Host pair will exchange: Hello/HelloAck + Heartbeat/HeartbeatAck + ErrorResponse for session negotiation (shared-secret + protocol major/minor mirroring Galaxy.Shared); OpenSessionRequest/Response + CloseSessionRequest carrying the declared FocasCncSeries so the Host picks up the pre-flight matrix; FocasAddressDto + FocasDataTypeCode for wire-compatible serialization of parsed addresses (0=Pmc/1=Param/2=Macro matches FocasAreaKind enum order so both sides cast (int)); ReadRequest/Response + WriteRequest/Response with MessagePack-serialized boxed values tagged by FocasDataTypeCode; PmcBitWriteRequest/Response as a first-class RMW operation so the critical section stays Host-side; Subscribe/Unsubscribe/OnDataChangeNotification for poll-loop-pushes-deltas model (FOCAS has no CNC-initiated callbacks); Probe + RuntimeStatusChange + Recycle surface for Tier-C supervision. Framing is [4-byte BE length][1-byte kind][body] with 16 MiB body cap matching Galaxy; FocasMessageKind byte values align with Galaxy ranges so an operator reading a hex dump doesn't have to context-switch. FrameReader/FrameWriter ported from Galaxy.Shared with thread-safe concurrent-write serialization. 24 new unit tests: 18 per-DTO round-trip tests covering every field + 6 framing tests (single-frame round-trip, clean-EOF returns null, oversized-length rejection, mid-frame EOF throws, 20-way concurrent-write ordering preserved, MessageKind byte values locked as wire-stable). No driver changes; existing 165 FOCAS unit tests still pass unchanged. PR B (Host skeleton) goes next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user