diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/FakeFocasBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/FakeFocasBackend.cs new file mode 100644 index 0000000..3f4bedf --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/FakeFocasBackend.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +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.Host.Backend; + +/// +/// In-memory for tests + an operational stub mode when +/// OTOPCUA_FOCAS_BACKEND=fake. Keeps per-address values keyed by a canonical +/// string; RMW semantics honor PMC bit-writes against the containing byte so the +/// PmcBitWriteRequest path can be exercised end-to-end without hardware. +/// +public sealed class FakeFocasBackend : IFocasBackend +{ + private readonly object _gate = new(); + private long _nextSessionId; + private readonly HashSet _openSessions = []; + private readonly Dictionary _pmcValues = []; + private readonly Dictionary _paramValues = []; + private readonly Dictionary _macroValues = []; + + public Task OpenSessionAsync(OpenSessionRequest request, CancellationToken ct) + { + lock (_gate) + { + var id = ++_nextSessionId; + _openSessions.Add(id); + return Task.FromResult(new OpenSessionResponse { Success = true, SessionId = id }); + } + } + + public Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct) + { + lock (_gate) { _openSessions.Remove(request.SessionId); } + return Task.CompletedTask; + } + + public Task ReadAsync(ReadRequest request, CancellationToken ct) + { + lock (_gate) + { + if (!_openSessions.Contains(request.SessionId)) + return Task.FromResult(new ReadResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" }); + + var store = StoreFor(request.Address.Kind); + var key = CanonicalKey(request.Address); + store.TryGetValue(key, out var value); + return Task.FromResult(new ReadResponse + { + Success = true, + StatusCode = 0, + ValueBytes = value ?? MessagePackSerializer.Serialize((int)0), + ValueTypeCode = request.DataType, + SourceTimestampUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }); + } + } + + public Task WriteAsync(WriteRequest request, CancellationToken ct) + { + lock (_gate) + { + if (!_openSessions.Contains(request.SessionId)) + return Task.FromResult(new WriteResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" }); + + var store = StoreFor(request.Address.Kind); + store[CanonicalKey(request.Address)] = request.ValueBytes ?? []; + return Task.FromResult(new WriteResponse { Success = true, StatusCode = 0 }); + } + } + + public Task PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct) + { + lock (_gate) + { + if (!_openSessions.Contains(request.SessionId)) + return Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" }); + if (request.BitIndex is < 0 or > 7) + return Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = 0x803C0000u, Error = "bit-out-of-range" }); + + var key = CanonicalKey(request.Address); + _pmcValues.TryGetValue(key, out var current); + current ??= MessagePackSerializer.Serialize((byte)0); + var b = MessagePackSerializer.Deserialize(current); + var mask = (byte)(1 << request.BitIndex); + b = request.Value ? (byte)(b | mask) : (byte)(b & ~mask); + _pmcValues[key] = MessagePackSerializer.Serialize(b); + return Task.FromResult(new PmcBitWriteResponse { Success = true, StatusCode = 0 }); + } + } + + public Task ProbeAsync(ProbeRequest request, CancellationToken ct) + { + lock (_gate) + { + return Task.FromResult(new ProbeResponse + { + Healthy = _openSessions.Contains(request.SessionId), + ObservedAtUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }); + } + } + + private Dictionary StoreFor(int kind) => kind switch + { + 0 => _pmcValues, + 1 => _paramValues, + 2 => _macroValues, + _ => _pmcValues, + }; + + private static string CanonicalKey(FocasAddressDto addr) => + addr.Kind switch + { + 0 => $"{addr.PmcLetter}{addr.Number}", + 1 => $"P{addr.Number}", + 2 => $"M{addr.Number}", + _ => $"?{addr.Number}", + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/IFocasBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/IFocasBackend.cs new file mode 100644 index 0000000..4176f08 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/IFocasBackend.cs @@ -0,0 +1,24 @@ +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend; + +/// +/// The Host's view of a FOCAS session. One implementation wraps the real +/// Fwlib32.dll via P/Invoke (lands with the real Fwlib32 integration follow-up, +/// since no hardware is available today); a second implementation — +/// — is used by tests. +/// Both live on .NET 4.8 x86 so the Host can be deployed in either mode without +/// changing the pipe server. +/// Invoked via FwlibFrameHandler in the Ipc namespace. +/// +public interface IFocasBackend +{ + Task OpenSessionAsync(OpenSessionRequest request, CancellationToken ct); + Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct); + Task ReadAsync(ReadRequest request, CancellationToken ct); + Task WriteAsync(WriteRequest request, CancellationToken ct); + Task PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct); + Task ProbeAsync(ProbeRequest request, CancellationToken ct); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/UnconfiguredFocasBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/UnconfiguredFocasBackend.cs new file mode 100644 index 0000000..4889739 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/UnconfiguredFocasBackend.cs @@ -0,0 +1,37 @@ +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend; + +/// +/// Safe default when the deployment hasn't configured a real Fwlib32 backend. +/// Returns structured failure responses instead of throwing so the Proxy can map the +/// error to BadDeviceFailure and surface a clear operator message pointing at +/// docs/v2/focas-deployment.md. Used when OTOPCUA_FOCAS_BACKEND is unset +/// or set to unconfigured. +/// +public sealed class UnconfiguredFocasBackend : IFocasBackend +{ + private const uint BadDeviceFailure = 0x80550000u; + private const string Reason = + "FOCAS Host is running without a real Fwlib32 backend. Set OTOPCUA_FOCAS_BACKEND=fwlib32 " + + "and ensure Fwlib32.dll is on PATH — see docs/v2/focas-deployment.md."; + + public Task OpenSessionAsync(OpenSessionRequest request, CancellationToken ct) => + Task.FromResult(new OpenSessionResponse { Success = false, Error = Reason, ErrorCode = "NoFwlibBackend" }); + + public Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct) => Task.CompletedTask; + + public Task ReadAsync(ReadRequest request, CancellationToken ct) => + Task.FromResult(new ReadResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason }); + + public Task WriteAsync(WriteRequest request, CancellationToken ct) => + Task.FromResult(new WriteResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason }); + + public Task PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct) => + Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason }); + + public Task ProbeAsync(ProbeRequest request, CancellationToken ct) => + Task.FromResult(new ProbeResponse { Healthy = false, Error = Reason, ObservedAtUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/FwlibFrameHandler.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/FwlibFrameHandler.cs new file mode 100644 index 0000000..d6e8ecf --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/FwlibFrameHandler.cs @@ -0,0 +1,111 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MessagePack; +using Serilog; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc; + +/// +/// Real FOCAS frame handler. Deserializes each request DTO, delegates to +/// , re-serializes the response. The backend owns the +/// Fwlib32 handle + STA thread — the handler is pure dispatch. +/// +public sealed class FwlibFrameHandler : IFrameHandler +{ + private readonly IFocasBackend _backend; + private readonly ILogger _logger; + + public FwlibFrameHandler(IFocasBackend backend, ILogger logger) + { + _backend = backend ?? throw new ArgumentNullException(nameof(backend)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct) + { + try + { + switch (kind) + { + case FocasMessageKind.Heartbeat: + { + var hb = MessagePackSerializer.Deserialize(body); + await writer.WriteAsync(FocasMessageKind.HeartbeatAck, + new HeartbeatAck + { + MonotonicTicks = hb.MonotonicTicks, + HostUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }, ct).ConfigureAwait(false); + return; + } + + case FocasMessageKind.OpenSessionRequest: + { + var req = MessagePackSerializer.Deserialize(body); + var resp = await _backend.OpenSessionAsync(req, ct).ConfigureAwait(false); + await writer.WriteAsync(FocasMessageKind.OpenSessionResponse, resp, ct).ConfigureAwait(false); + return; + } + + case FocasMessageKind.CloseSessionRequest: + { + var req = MessagePackSerializer.Deserialize(body); + await _backend.CloseSessionAsync(req, ct).ConfigureAwait(false); + return; + } + + case FocasMessageKind.ReadRequest: + { + var req = MessagePackSerializer.Deserialize(body); + var resp = await _backend.ReadAsync(req, ct).ConfigureAwait(false); + await writer.WriteAsync(FocasMessageKind.ReadResponse, resp, ct).ConfigureAwait(false); + return; + } + + case FocasMessageKind.WriteRequest: + { + var req = MessagePackSerializer.Deserialize(body); + var resp = await _backend.WriteAsync(req, ct).ConfigureAwait(false); + await writer.WriteAsync(FocasMessageKind.WriteResponse, resp, ct).ConfigureAwait(false); + return; + } + + case FocasMessageKind.PmcBitWriteRequest: + { + var req = MessagePackSerializer.Deserialize(body); + var resp = await _backend.PmcBitWriteAsync(req, ct).ConfigureAwait(false); + await writer.WriteAsync(FocasMessageKind.PmcBitWriteResponse, resp, ct).ConfigureAwait(false); + return; + } + + case FocasMessageKind.ProbeRequest: + { + var req = MessagePackSerializer.Deserialize(body); + var resp = await _backend.ProbeAsync(req, ct).ConfigureAwait(false); + await writer.WriteAsync(FocasMessageKind.ProbeResponse, resp, ct).ConfigureAwait(false); + return; + } + + default: + await writer.WriteAsync(FocasMessageKind.ErrorResponse, + new ErrorResponse { Code = "unknown-kind", Message = $"Kind {kind} is not handled by the Host" }, + ct).ConfigureAwait(false); + return; + } + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + _logger.Error(ex, "FwlibFrameHandler error processing {Kind}", kind); + await writer.WriteAsync(FocasMessageKind.ErrorResponse, + new ErrorResponse { Code = "backend-exception", Message = ex.Message }, + ct).ConfigureAwait(false); + } + } + + public IDisposable AttachConnection(FrameWriter writer) => IFrameHandler.NoopAttachment.Instance; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs index 6450747..05ab13e 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs @@ -2,6 +2,7 @@ using System; using System.Security.Principal; using System.Threading; using Serilog; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host; @@ -44,9 +45,18 @@ public static class Program Log.Information("OtOpcUaFocasHost starting — pipe={Pipe} allowedSid={Sid}", pipeName, allowedSidValue); - var handler = new StubFrameHandler(); - Log.Warning("OtOpcUaFocasHost backend=stub — Fwlib32 lift lands in PR C"); + var backendKind = (Environment.GetEnvironmentVariable("OTOPCUA_FOCAS_BACKEND") ?? "unconfigured") + .ToLowerInvariant(); + IFocasBackend backend = backendKind switch + { + "fake" => new FakeFocasBackend(), + "unconfigured" => new UnconfiguredFocasBackend(), + "fwlib32" => new UnconfiguredFocasBackend(), // real Fwlib32 backend lands with hardware integration follow-up + _ => new UnconfiguredFocasBackend(), + }; + Log.Information("OtOpcUaFocasHost backend={Backend}", backendKind); + var handler = new FwlibFrameHandler(backend, Log.Logger); server.RunAsync(handler, cts.Token).GetAwaiter().GetResult(); Log.Information("OtOpcUaFocasHost stopped cleanly"); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/FocasIpcClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/FocasIpcClient.cs new file mode 100644 index 0000000..51ede9b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/FocasIpcClient.cs @@ -0,0 +1,120 @@ +using System.IO; +using System.IO.Pipes; +using MessagePack; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc; + +/// +/// Proxy-side IPC channel to a running Driver.FOCAS.Host. Owns the pipe connection +/// and serializes request/response round-trips through a single call gate so +/// concurrent callers don't interleave frames. One instance per FOCAS Host session. +/// +public sealed class FocasIpcClient : IAsyncDisposable +{ + private readonly Stream _stream; + private readonly FrameReader _reader; + private readonly FrameWriter _writer; + private readonly SemaphoreSlim _callGate = new(1, 1); + + private FocasIpcClient(Stream stream) + { + _stream = stream; + _reader = new FrameReader(stream, leaveOpen: true); + _writer = new FrameWriter(stream, leaveOpen: true); + } + + /// Named-pipe factory: connects, sends Hello, awaits HelloAck. + public static async Task ConnectAsync( + string pipeName, string sharedSecret, TimeSpan connectTimeout, CancellationToken ct) + { + var stream = new NamedPipeClientStream( + serverName: ".", + pipeName: pipeName, + direction: PipeDirection.InOut, + options: PipeOptions.Asynchronous); + + await stream.ConnectAsync((int)connectTimeout.TotalMilliseconds, ct); + return await HandshakeAsync(stream, sharedSecret, ct).ConfigureAwait(false); + } + + /// + /// Stream factory — used by tests that wire the Proxy against an in-memory stream + /// pair instead of a real pipe. is owned by the caller + /// until . + /// + public static Task ConnectAsync(Stream stream, string sharedSecret, CancellationToken ct) + => HandshakeAsync(stream, sharedSecret, ct); + + private static async Task HandshakeAsync(Stream stream, string sharedSecret, CancellationToken ct) + { + var client = new FocasIpcClient(stream); + try + { + await client._writer.WriteAsync(FocasMessageKind.Hello, + new Hello { PeerName = "FOCAS.Proxy", SharedSecret = sharedSecret }, ct).ConfigureAwait(false); + + var ack = await client._reader.ReadFrameAsync(ct).ConfigureAwait(false); + if (ack is null || ack.Value.Kind != FocasMessageKind.HelloAck) + throw new InvalidOperationException("Did not receive HelloAck from FOCAS.Host"); + + var ackMsg = FrameReader.Deserialize(ack.Value.Body); + if (!ackMsg.Accepted) + throw new UnauthorizedAccessException($"FOCAS.Host rejected Hello: {ackMsg.RejectReason}"); + + return client; + } + catch + { + await client.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + public async Task CallAsync( + FocasMessageKind requestKind, TReq request, FocasMessageKind expectedResponseKind, CancellationToken ct) + { + await _callGate.WaitAsync(ct).ConfigureAwait(false); + try + { + await _writer.WriteAsync(requestKind, request, ct).ConfigureAwait(false); + + var frame = await _reader.ReadFrameAsync(ct).ConfigureAwait(false); + if (frame is null) throw new EndOfStreamException("FOCAS IPC peer closed before response"); + + if (frame.Value.Kind == FocasMessageKind.ErrorResponse) + { + var err = MessagePackSerializer.Deserialize(frame.Value.Body); + throw new FocasIpcException(err.Code, err.Message); + } + + if (frame.Value.Kind != expectedResponseKind) + throw new InvalidOperationException( + $"Expected {expectedResponseKind}, got {frame.Value.Kind}"); + + return MessagePackSerializer.Deserialize(frame.Value.Body); + } + finally { _callGate.Release(); } + } + + public async Task SendOneWayAsync(FocasMessageKind requestKind, TReq request, CancellationToken ct) + { + await _callGate.WaitAsync(ct).ConfigureAwait(false); + try { await _writer.WriteAsync(requestKind, request, ct).ConfigureAwait(false); } + finally { _callGate.Release(); } + } + + public async ValueTask DisposeAsync() + { + _callGate.Dispose(); + _reader.Dispose(); + _writer.Dispose(); + await _stream.DisposeAsync().ConfigureAwait(false); + } +} + +public sealed class FocasIpcException(string code, string message) : Exception($"[{code}] {message}") +{ + public string Code { get; } = code; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/IpcFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/IpcFocasClient.cs new file mode 100644 index 0000000..01c227c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/IpcFocasClient.cs @@ -0,0 +1,199 @@ +using MessagePack; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc; + +/// +/// implementation that forwards every operation over a +/// to a Driver.FOCAS.Host process. Keeps the +/// Fwlib32.dll P/Invoke out of the main server process so a native crash +/// blast-radius stops at the Host boundary. +/// +/// +/// Session lifecycle: sends OpenSessionRequest and +/// caches the returned SessionId. Subsequent / +/// / calls thread that session id +/// onto each request DTO. sends CloseSessionRequest + +/// disposes the underlying pipe. +/// +public sealed class IpcFocasClient : IFocasClient +{ + private readonly FocasIpcClient _ipc; + private readonly FocasCncSeries _series; + private long _sessionId; + private bool _connected; + + public IpcFocasClient(FocasIpcClient ipc, FocasCncSeries series = FocasCncSeries.Unknown) + { + _ipc = ipc ?? throw new ArgumentNullException(nameof(ipc)); + _series = series; + } + + public bool IsConnected => _connected; + + public async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken) + { + if (_connected) return; + + var resp = await _ipc.CallAsync( + FocasMessageKind.OpenSessionRequest, + new OpenSessionRequest + { + HostAddress = $"{address.Host}:{address.Port}", + TimeoutMs = (int)Math.Max(1, timeout.TotalMilliseconds), + CncSeries = (int)_series, + }, + FocasMessageKind.OpenSessionResponse, + cancellationToken).ConfigureAwait(false); + + if (!resp.Success) + throw new InvalidOperationException( + $"FOCAS Host rejected OpenSession for {address}: {resp.ErrorCode ?? "?"} — {resp.Error}"); + + _sessionId = resp.SessionId; + _connected = true; + } + + public async Task<(object? value, uint status)> ReadAsync( + FocasAddress address, FocasDataType type, CancellationToken cancellationToken) + { + if (!_connected) return (null, FocasStatusMapper.BadCommunicationError); + + var resp = await _ipc.CallAsync( + FocasMessageKind.ReadRequest, + new ReadRequest + { + SessionId = _sessionId, + Address = ToDto(address), + DataType = (int)type, + }, + FocasMessageKind.ReadResponse, + cancellationToken).ConfigureAwait(false); + + if (!resp.Success) return (null, resp.StatusCode); + + var value = DecodeValue(resp.ValueBytes, resp.ValueTypeCode); + return (value, resp.StatusCode); + } + + public async Task WriteAsync( + FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) + { + if (!_connected) return FocasStatusMapper.BadCommunicationError; + + // PMC bit writes get the first-class RMW frame so the critical section stays on the Host. + if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit) + { + var bitResp = await _ipc.CallAsync( + FocasMessageKind.PmcBitWriteRequest, + new PmcBitWriteRequest + { + SessionId = _sessionId, + Address = ToDto(address), + BitIndex = bit, + Value = Convert.ToBoolean(value), + }, + FocasMessageKind.PmcBitWriteResponse, + cancellationToken).ConfigureAwait(false); + return bitResp.StatusCode; + } + + var resp = await _ipc.CallAsync( + FocasMessageKind.WriteRequest, + new WriteRequest + { + SessionId = _sessionId, + Address = ToDto(address), + DataType = (int)type, + ValueTypeCode = (int)type, + ValueBytes = EncodeValue(value, type), + }, + FocasMessageKind.WriteResponse, + cancellationToken).ConfigureAwait(false); + + return resp.StatusCode; + } + + public async Task ProbeAsync(CancellationToken cancellationToken) + { + if (!_connected) return false; + try + { + var resp = await _ipc.CallAsync( + FocasMessageKind.ProbeRequest, + new ProbeRequest { SessionId = _sessionId }, + FocasMessageKind.ProbeResponse, + cancellationToken).ConfigureAwait(false); + return resp.Healthy; + } + catch { return false; } + } + + public void Dispose() + { + if (_connected) + { + try + { + _ipc.SendOneWayAsync(FocasMessageKind.CloseSessionRequest, + new CloseSessionRequest { SessionId = _sessionId }, CancellationToken.None) + .GetAwaiter().GetResult(); + } + catch { /* best effort */ } + _connected = false; + } + _ipc.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + private static FocasAddressDto ToDto(FocasAddress addr) => new() + { + Kind = (int)addr.Kind, + PmcLetter = addr.PmcLetter, + Number = addr.Number, + BitIndex = addr.BitIndex, + }; + + private static byte[]? EncodeValue(object? value, FocasDataType type) + { + if (value is null) return null; + return type switch + { + FocasDataType.Bit => MessagePackSerializer.Serialize(Convert.ToBoolean(value)), + FocasDataType.Byte => MessagePackSerializer.Serialize(Convert.ToByte(value)), + FocasDataType.Int16 => MessagePackSerializer.Serialize(Convert.ToInt16(value)), + FocasDataType.Int32 => MessagePackSerializer.Serialize(Convert.ToInt32(value)), + FocasDataType.Float32 => MessagePackSerializer.Serialize(Convert.ToSingle(value)), + FocasDataType.Float64 => MessagePackSerializer.Serialize(Convert.ToDouble(value)), + FocasDataType.String => MessagePackSerializer.Serialize(Convert.ToString(value) ?? string.Empty), + _ => MessagePackSerializer.Serialize(Convert.ToInt32(value)), + }; + } + + private static object? DecodeValue(byte[]? bytes, int typeCode) + { + if (bytes is null) return null; + return typeCode switch + { + FocasDataTypeCode.Bit => MessagePackSerializer.Deserialize(bytes), + FocasDataTypeCode.Byte => MessagePackSerializer.Deserialize(bytes), + FocasDataTypeCode.Int16 => MessagePackSerializer.Deserialize(bytes), + FocasDataTypeCode.Int32 => MessagePackSerializer.Deserialize(bytes), + FocasDataTypeCode.Float32 => MessagePackSerializer.Deserialize(bytes), + FocasDataTypeCode.Float64 => MessagePackSerializer.Deserialize(bytes), + FocasDataTypeCode.String => MessagePackSerializer.Deserialize(bytes), + _ => MessagePackSerializer.Deserialize(bytes), + }; + } +} + +/// +/// Factory producing s. One pipe connection per +/// IFocasClient — matches the driver's one-client-per-device invariant. The +/// deployment wires this into the DI container in place of +/// . +/// +public sealed class IpcFocasClientFactory(Func ipcClientFactory, FocasCncSeries series = FocasCncSeries.Unknown) + : IFocasClientFactory +{ + public IFocasClient Create() => new IpcFocasClient(ipcClientFactory(), series); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj index 15b82fe..a17c4d7 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj @@ -14,6 +14,7 @@ +