diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 3abbb56..90b53da 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -15,12 +15,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; /// + the default makes misconfigured servers /// fail fast. /// -public sealed class FocasDriver : IDriver, IDisposable, IAsyncDisposable +public sealed class FocasDriver : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable { private readonly FocasDriverOptions _options; private readonly string _driverInstanceId; private readonly IFocasClientFactory _clientFactory; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); private DriverHealth _health = new(DriverState.Unknown, null, null); public FocasDriver(FocasDriverOptions options, string driverInstanceId, @@ -29,7 +30,7 @@ public sealed class FocasDriver : IDriver, IDisposable, IAsyncDisposable ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; - _clientFactory = clientFactory ?? new UnimplementedFocasClientFactory(); + _clientFactory = clientFactory ?? new FwlibFocasClientFactory(); } public string DriverInstanceId => _driverInstanceId; @@ -47,6 +48,7 @@ public sealed class FocasDriver : IDriver, IDisposable, IAsyncDisposable $"FOCAS device has invalid HostAddress '{device.HostAddress}' — expected 'focas://{{ip}}[:{{port}}]'."); _devices[device.HostAddress] = new DeviceState(addr, device); } + foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag; _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); } catch (Exception ex) @@ -65,7 +67,9 @@ public sealed class FocasDriver : IDriver, IDisposable, IAsyncDisposable public Task ShutdownAsync(CancellationToken cancellationToken) { + foreach (var state in _devices.Values) state.DisposeClient(); _devices.Clear(); + _tagsByName.Clear(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); return Task.CompletedTask; } @@ -78,6 +82,130 @@ public sealed class FocasDriver : IDriver, IDisposable, IAsyncDisposable internal DeviceState? GetDeviceState(string hostAddress) => _devices.TryGetValue(hostAddress, out var s) ? s : null; + // ---- IReadable ---- + + public async Task> ReadAsync( + IReadOnlyList fullReferences, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(fullReferences); + var now = DateTime.UtcNow; + var results = new DataValueSnapshot[fullReferences.Count]; + + for (var i = 0; i < fullReferences.Count; i++) + { + var reference = fullReferences[i]; + if (!_tagsByName.TryGetValue(reference, out var def)) + { + results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); + continue; + } + if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + { + results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); + continue; + } + + try + { + var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); + var parsed = FocasAddress.TryParse(def.Address) + ?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'."); + var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false); + + results[i] = new DataValueSnapshot(value, status, now, now); + if (status == FocasStatusMapper.Good) + _health = new DriverHealth(DriverState.Healthy, now, null); + else + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, + $"FOCAS status 0x{status:X8} reading {reference}"); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); + } + } + + return results; + } + + // ---- IWritable ---- + + public async Task> WriteAsync( + IReadOnlyList writes, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(writes); + var results = new WriteResult[writes.Count]; + + for (var i = 0; i < writes.Count; i++) + { + var w = writes[i]; + if (!_tagsByName.TryGetValue(w.FullReference, out var def)) + { + results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown); + continue; + } + if (!def.Writable) + { + results[i] = new WriteResult(FocasStatusMapper.BadNotWritable); + continue; + } + if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + { + results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown); + continue; + } + + try + { + var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); + var parsed = FocasAddress.TryParse(def.Address) + ?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'."); + var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false); + results[i] = new WriteResult(status); + } + catch (OperationCanceledException) { throw; } + catch (NotSupportedException nse) + { + results[i] = new WriteResult(FocasStatusMapper.BadNotSupported); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message); + } + catch (Exception ex) when (ex is FormatException or InvalidCastException) + { + results[i] = new WriteResult(FocasStatusMapper.BadTypeMismatch); + } + catch (OverflowException) + { + results[i] = new WriteResult(FocasStatusMapper.BadOutOfRange); + } + catch (Exception ex) + { + results[i] = new WriteResult(FocasStatusMapper.BadCommunicationError); + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); + } + } + + return results; + } + + private async Task EnsureConnectedAsync(DeviceState device, CancellationToken ct) + { + if (device.Client is { IsConnected: true } c) return c; + device.Client ??= _clientFactory.Create(); + try + { + await device.Client.ConnectAsync(device.ParsedAddress, _options.Timeout, ct).ConfigureAwait(false); + } + catch + { + device.Client.Dispose(); + device.Client = null; + throw; + } + return device.Client; + } + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); @@ -85,5 +213,12 @@ public sealed class FocasDriver : IDriver, IDisposable, IAsyncDisposable { public FocasHostAddress ParsedAddress { get; } = parsedAddress; public FocasDeviceOptions Options { get; } = options; + public IFocasClient? Client { get; set; } + + public void DisposeClient() + { + Client?.Dispose(); + Client = null; + } } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs new file mode 100644 index 0000000..5fb65e6 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs @@ -0,0 +1,269 @@ +using System.Buffers.Binary; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// +/// implementation backed by Fanuc's licensed +/// Fwlib32.dll via P/Invoke. The DLL is NOT shipped with +/// OtOpcUa; the deployment places it next to the server executable or on PATH +/// (per Fanuc licensing — see docs/v2/focas-deployment.md). +/// +/// +/// Construction is licence-safe — .NET P/Invoke is lazy, so instantiating this class +/// does NOT load Fwlib32.dll. The DLL only loads on the first wire call (Connect / +/// Read / Write / Probe). When missing, those calls throw +/// which the driver surfaces as BadCommunicationError through the normal exception +/// mapping. +/// +/// Session-scoped handle — cnc_allclibhndl3 opens one FWLIB handle per CNC; +/// all PMC / parameter / macro reads on that device go through the same handle. Dispose +/// calls cnc_freelibhndl. +/// +internal sealed class FwlibFocasClient : IFocasClient +{ + private ushort _handle; + private bool _connected; + + public bool IsConnected => _connected; + + public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken) + { + if (_connected) return Task.CompletedTask; + + var timeoutMs = (int)Math.Max(1, timeout.TotalMilliseconds); + var ret = FwlibNative.AllcLibHndl3(address.Host, (ushort)address.Port, timeoutMs, out var handle); + if (ret != 0) + throw new InvalidOperationException( + $"FWLIB cnc_allclibhndl3 failed with EW_{ret} connecting to {address}."); + _handle = handle; + _connected = true; + return Task.CompletedTask; + } + + public Task<(object? value, uint status)> ReadAsync( + FocasAddress address, FocasDataType type, CancellationToken cancellationToken) + { + if (!_connected) return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadCommunicationError)); + cancellationToken.ThrowIfCancellationRequested(); + + return address.Kind switch + { + FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)), + FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)), + FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)), + _ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)), + }; + } + + public Task WriteAsync( + FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) + { + if (!_connected) return Task.FromResult(FocasStatusMapper.BadCommunicationError); + cancellationToken.ThrowIfCancellationRequested(); + + return address.Kind switch + { + FocasAreaKind.Pmc => Task.FromResult(WritePmc(address, type, value)), + FocasAreaKind.Parameter => Task.FromResult(WriteParameter(address, type, value)), + FocasAreaKind.Macro => Task.FromResult(WriteMacro(address, value)), + _ => Task.FromResult(FocasStatusMapper.BadNotSupported), + }; + } + + public Task ProbeAsync(CancellationToken cancellationToken) + { + if (!_connected) return Task.FromResult(false); + var buf = new FwlibNative.ODBST(); + var ret = FwlibNative.StatInfo(_handle, ref buf); + return Task.FromResult(ret == 0); + } + + // ---- PMC ---- + + private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type) + { + var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") + ?? throw new InvalidOperationException($"Unknown PMC letter '{address.PmcLetter}'."); + var dataType = FocasPmcDataType.FromFocasDataType(type); + var length = PmcReadLength(type); + + var buf = new FwlibNative.IODBPMC { Data = new byte[40] }; + var ret = FwlibNative.PmcRdPmcRng( + _handle, addrType, dataType, + (ushort)address.Number, (ushort)address.Number, (ushort)length, ref buf); + if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret)); + + var value = type switch + { + FocasDataType.Bit => ExtractBit(buf.Data[0], address.BitIndex ?? 0), + FocasDataType.Byte => (object)(sbyte)buf.Data[0], + FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data), + FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data), + FocasDataType.Float32 => (object)BinaryPrimitives.ReadSingleLittleEndian(buf.Data), + FocasDataType.Float64 => (object)BinaryPrimitives.ReadDoubleLittleEndian(buf.Data), + _ => (object)buf.Data[0], + }; + return (value, FocasStatusMapper.Good); + } + + private uint WritePmc(FocasAddress address, FocasDataType type, object? value) + { + var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0; + var dataType = FocasPmcDataType.FromFocasDataType(type); + var length = PmcWriteLength(type); + + var buf = new FwlibNative.IODBPMC + { + TypeA = addrType, + TypeD = dataType, + DatanoS = (ushort)address.Number, + DatanoE = (ushort)address.Number, + Data = new byte[40], + }; + EncodePmcValue(buf.Data, type, value, address.BitIndex); + + var ret = FwlibNative.PmcWrPmcRng(_handle, (ushort)length, ref buf); + return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret); + } + + private (object? value, uint status) ReadParameter(FocasAddress address, FocasDataType type) + { + var buf = new FwlibNative.IODBPSD { Data = new byte[32] }; + var length = ParamReadLength(type); + var ret = FwlibNative.RdParam(_handle, (ushort)address.Number, axis: 0, (short)length, ref buf); + if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret)); + + var value = type switch + { + FocasDataType.Bit when address.BitIndex is int bit => ExtractBit(buf.Data[0], bit), + FocasDataType.Byte => (object)(sbyte)buf.Data[0], + FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data), + FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data), + _ => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data), + }; + return (value, FocasStatusMapper.Good); + } + + private uint WriteParameter(FocasAddress address, FocasDataType type, object? value) + { + var buf = new FwlibNative.IODBPSD + { + Datano = (short)address.Number, + Type = 0, + Data = new byte[32], + }; + var length = ParamReadLength(type); + EncodeParamValue(buf.Data, type, value); + var ret = FwlibNative.WrParam(_handle, (short)length, ref buf); + return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret); + } + + private (object? value, uint status) ReadMacro(FocasAddress address) + { + var buf = new FwlibNative.ODBM(); + var ret = FwlibNative.RdMacro(_handle, (short)address.Number, length: 8, ref buf); + if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret)); + + // Macro value = mcr_val / 10^dec_val. Convert to double so callers get the correct + // scaled value regardless of the decimal-point count the CNC reports. + var scaled = buf.McrVal / Math.Pow(10.0, buf.DecVal); + return (scaled, FocasStatusMapper.Good); + } + + private uint WriteMacro(FocasAddress address, object? value) + { + // Write as integer + 0 decimal places — callers that need decimal precision can extend + // this via a future WriteMacroScaled overload. Consistent with what most HMIs do today. + var intValue = Convert.ToInt32(value); + var ret = FwlibNative.WrMacro(_handle, (short)address.Number, length: 8, intValue, decimalPointCount: 0); + return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret); + } + + public void Dispose() + { + if (_connected) + { + try { FwlibNative.FreeLibHndl(_handle); } catch { } + _connected = false; + } + } + + // ---- helpers ---- + + private static int PmcReadLength(FocasDataType type) => type switch + { + FocasDataType.Bit or FocasDataType.Byte => 8 + 1, // 8-byte header + 1 byte payload + FocasDataType.Int16 => 8 + 2, + FocasDataType.Int32 => 8 + 4, + FocasDataType.Float32 => 8 + 4, + FocasDataType.Float64 => 8 + 8, + _ => 8 + 1, + }; + + private static int PmcWriteLength(FocasDataType type) => PmcReadLength(type); + private static int ParamReadLength(FocasDataType type) => type switch + { + FocasDataType.Bit or FocasDataType.Byte => 4 + 1, + FocasDataType.Int16 => 4 + 2, + FocasDataType.Int32 => 4 + 4, + _ => 4 + 4, + }; + + private static bool ExtractBit(byte word, int bit) => (word & (1 << bit)) != 0; + + internal static void EncodePmcValue(byte[] data, FocasDataType type, object? value, int? bitIndex) + { + switch (type) + { + case FocasDataType.Bit: + // Bit-in-byte write is a read-modify-write at the PMC level — the underlying + // pmc_wrpmcrng takes a byte payload, so caller must set the bit on a byte they + // just read. This path is flagged for the follow-up RMW work in task #181. + throw new NotSupportedException( + "FOCAS Bit writes require read-modify-write; tracked in task #181."); + case FocasDataType.Byte: + data[0] = (byte)(sbyte)Convert.ToSByte(value); + break; + case FocasDataType.Int16: + BinaryPrimitives.WriteInt16LittleEndian(data, Convert.ToInt16(value)); + break; + case FocasDataType.Int32: + BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value)); + break; + case FocasDataType.Float32: + BinaryPrimitives.WriteSingleLittleEndian(data, Convert.ToSingle(value)); + break; + case FocasDataType.Float64: + BinaryPrimitives.WriteDoubleLittleEndian(data, Convert.ToDouble(value)); + break; + default: + throw new NotSupportedException($"FocasDataType {type} not writable via PMC."); + } + _ = bitIndex; // bit-in-byte handled above + } + + internal static void EncodeParamValue(byte[] data, FocasDataType type, object? value) + { + switch (type) + { + case FocasDataType.Byte: + data[0] = (byte)(sbyte)Convert.ToSByte(value); + break; + case FocasDataType.Int16: + BinaryPrimitives.WriteInt16LittleEndian(data, Convert.ToInt16(value)); + break; + case FocasDataType.Int32: + BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value)); + break; + default: + BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value)); + break; + } + } +} + +/// Default — produces a fresh per device. +public sealed class FwlibFocasClientFactory : IFocasClientFactory +{ + public IFocasClient Create() => new FwlibFocasClient(); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs new file mode 100644 index 0000000..08c2761 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs @@ -0,0 +1,190 @@ +using System.Runtime.InteropServices; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// +/// P/Invoke surface for Fanuc FWLIB (Fwlib32.dll). Declarations extracted from +/// fwlib32.h in the strangesast/fwlib repo; the licensed DLL itself is NOT shipped +/// with OtOpcUa — the deployment places Fwlib32.dll next to the server executable +/// or on PATH. +/// +/// +/// Deliberately narrow — only the calls actually makes. +/// FOCAS has 800+ functions in fwlib32.h; pulling in every one would bloat the +/// P/Invoke surface + signal more coverage than this driver provides. Expand as capabilities +/// are added. +/// +internal static class FwlibNative +{ + private const string Library = "Fwlib32.dll"; + + // ---- Handle lifetime ---- + + /// Open an Ethernet FWLIB handle. Returns EW_OK (0) on success; handle written out. + [DllImport(Library, EntryPoint = "cnc_allclibhndl3", CharSet = CharSet.Ansi, ExactSpelling = true)] + public static extern short AllcLibHndl3( + [MarshalAs(UnmanagedType.LPStr)] string ipaddr, + ushort port, + int timeout, + out ushort handle); + + [DllImport(Library, EntryPoint = "cnc_freelibhndl", ExactSpelling = true)] + public static extern short FreeLibHndl(ushort handle); + + // ---- PMC ---- + + /// PMC range read. is the ADR_* enum; is 0 byte / 1 word / 2 long. + [DllImport(Library, EntryPoint = "pmc_rdpmcrng", ExactSpelling = true)] + public static extern short PmcRdPmcRng( + ushort handle, + short addrType, + short dataType, + ushort startNumber, + ushort endNumber, + ushort length, + ref IODBPMC buffer); + + [DllImport(Library, EntryPoint = "pmc_wrpmcrng", ExactSpelling = true)] + public static extern short PmcWrPmcRng( + ushort handle, + ushort length, + ref IODBPMC buffer); + + // ---- Parameters ---- + + [DllImport(Library, EntryPoint = "cnc_rdparam", ExactSpelling = true)] + public static extern short RdParam( + ushort handle, + ushort number, + short axis, + short length, + ref IODBPSD buffer); + + [DllImport(Library, EntryPoint = "cnc_wrparam", ExactSpelling = true)] + public static extern short WrParam( + ushort handle, + short length, + ref IODBPSD buffer); + + // ---- Macro variables ---- + + [DllImport(Library, EntryPoint = "cnc_rdmacro", ExactSpelling = true)] + public static extern short RdMacro( + ushort handle, + short number, + short length, + ref ODBM buffer); + + [DllImport(Library, EntryPoint = "cnc_wrmacro", ExactSpelling = true)] + public static extern short WrMacro( + ushort handle, + short number, + short length, + int macroValue, + short decimalPointCount); + + // ---- Status ---- + + [DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)] + public static extern short StatInfo(ushort handle, ref ODBST buffer); + + // ---- Structs ---- + + /// + /// IODBPMC — PMC range I/O buffer. 8-byte header + 40-byte union. We marshal the union + /// as a fixed byte buffer + interpret per on the managed side. + /// + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct IODBPMC + { + public short TypeA; + public short TypeD; + public ushort DatanoS; + public ushort DatanoE; + // 40-byte union: cdata[5] / idata[5] / ldata[5] / fdata[5] / dbdata[5] — dbdata is the widest. + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 40)] + public byte[] Data; + } + + /// + /// IODBPSD — CNC parameter I/O buffer. Axis-aware; for non-axis parameters pass axis=0. + /// Union payload is bytes / shorts / longs — we marshal 32 bytes as the widest slot. + /// + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct IODBPSD + { + public short Datano; + public short Type; // axis index (0 for non-axis) + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)] + public byte[] Data; + } + + /// ODBM — macro variable read buffer. Value = McrVal / 10^DecVal. + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct ODBM + { + public short Datano; + public short Dummy; + public int McrVal; // long in C; 32-bit signed + public short DecVal; // decimal-point count + } + + /// ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode. + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct ODBST + { + public short Dummy; + public short TmMode; + public short Aut; + public short Run; + public short Motion; + public short Mstb; + public short Emergency; + public short Alarm; + public short Edit; + } +} + +/// +/// PMC address-letter → FOCAS ADR_* numeric code. Per Fanuc FOCAS/2 spec the codes +/// are: G=0, F=1, Y=2, X=3, A=4, R=5, T=6, K=7, C=8, D=9, E=10. Exposed internally + +/// tested so the FwlibFocasClient translation is verifiable without the DLL loaded. +/// +internal static class FocasPmcAddrType +{ + public static short? FromLetter(string letter) => letter.ToUpperInvariant() switch + { + "G" => 0, + "F" => 1, + "Y" => 2, + "X" => 3, + "A" => 4, + "R" => 5, + "T" => 6, + "K" => 7, + "C" => 8, + "D" => 9, + "E" => 10, + _ => null, + }; +} + +/// PMC data-type numeric codes per FOCAS/2: 0 = byte, 1 = word, 2 = long, 4 = float, 5 = double. +internal static class FocasPmcDataType +{ + public const short Byte = 0; + public const short Word = 1; + public const short Long = 2; + public const short Float = 4; + public const short Double = 5; + + public static short FromFocasDataType(FocasDataType t) => t switch + { + FocasDataType.Bit or FocasDataType.Byte => Byte, + FocasDataType.Int16 => Word, + FocasDataType.Int32 => Long, + FocasDataType.Float32 => Float, + FocasDataType.Float64 => Double, + _ => Byte, + }; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs new file mode 100644 index 0000000..c15dbf3 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs @@ -0,0 +1,69 @@ +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +internal class FakeFocasClient : IFocasClient +{ + public bool IsConnected { get; private set; } + public int ConnectCount { get; private set; } + public int DisposeCount { get; private set; } + public bool ThrowOnConnect { get; set; } + public bool ThrowOnRead { get; set; } + public bool ThrowOnWrite { get; set; } + public bool ProbeResult { get; set; } = true; + public Exception? Exception { get; set; } + + public Dictionary Values { get; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase); + public List<(FocasAddress addr, FocasDataType type, object? value)> WriteLog { get; } = new(); + + public virtual Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct) + { + ConnectCount++; + if (ThrowOnConnect) throw Exception ?? new InvalidOperationException(); + IsConnected = true; + return Task.CompletedTask; + } + + public virtual Task<(object? value, uint status)> ReadAsync( + FocasAddress address, FocasDataType type, CancellationToken ct) + { + if (ThrowOnRead) throw Exception ?? new InvalidOperationException(); + var key = address.Canonical; + var status = ReadStatuses.TryGetValue(key, out var s) ? s : FocasStatusMapper.Good; + var value = Values.TryGetValue(key, out var v) ? v : null; + return Task.FromResult((value, status)); + } + + public virtual Task WriteAsync( + FocasAddress address, FocasDataType type, object? value, CancellationToken ct) + { + if (ThrowOnWrite) throw Exception ?? new InvalidOperationException(); + WriteLog.Add((address, type, value)); + Values[address.Canonical] = value; + var status = WriteStatuses.TryGetValue(address.Canonical, out var s) ? s : FocasStatusMapper.Good; + return Task.FromResult(status); + } + + public virtual Task ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult); + + public virtual void Dispose() + { + DisposeCount++; + IsConnected = false; + } +} + +internal sealed class FakeFocasClientFactory : IFocasClientFactory +{ + public List Clients { get; } = new(); + public Func? Customise { get; set; } + + public IFocasClient Create() + { + var c = Customise?.Invoke() ?? new FakeFocasClient(); + Clients.Add(c); + return c; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadWriteTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadWriteTests.cs new file mode 100644 index 0000000..95e4dd8 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadWriteTests.cs @@ -0,0 +1,261 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +[Trait("Category", "Unit")] +public sealed class FocasReadWriteTests +{ + private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(params FocasTagDefinition[] tags) + { + var factory = new FakeFocasClientFactory(); + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], + Tags = tags, + Probe = new FocasProbeOptions { Enabled = false }, + }, "drv-1", factory); + return (drv, factory); + } + + // ---- Read ---- + + [Fact] + public async Task Unknown_reference_maps_to_BadNodeIdUnknown() + { + var (drv, _) = NewDriver(); + await drv.InitializeAsync("{}", CancellationToken.None); + + var snapshots = await drv.ReadAsync(["missing"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown); + } + + [Fact] + public async Task Successful_PMC_read_returns_Good_value() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)5 } }; + + var snapshots = await drv.ReadAsync(["Run"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); + snapshots.Single().Value.ShouldBe((sbyte)5); + } + + [Fact] + public async Task Parameter_read_routes_through_FocasAddress_Parameter_kind() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("Accel", "focas://10.0.0.5:8193", "PARAM:1820", FocasDataType.Int32)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeFocasClient { Values = { ["PARAM:1820"] = 1500 } }; + + var snapshots = await drv.ReadAsync(["Accel"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); + snapshots.Single().Value.ShouldBe(1500); + } + + [Fact] + public async Task Macro_read_routes_through_FocasAddress_Macro_kind() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeFocasClient { Values = { ["MACRO:500"] = 3.14159 } }; + + var snapshots = await drv.ReadAsync(["CustomVar"], CancellationToken.None); + snapshots.Single().Value.ShouldBe(3.14159); + } + + [Fact] + public async Task Repeat_read_reuses_connection() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } }; + + await drv.ReadAsync(["X"], CancellationToken.None); + await drv.ReadAsync(["X"], CancellationToken.None); + + factory.Clients.Count.ShouldBe(1); + factory.Clients[0].ConnectCount.ShouldBe(1); + } + + [Fact] + public async Task FOCAS_error_status_maps_via_status_mapper() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("Ghost", "focas://10.0.0.5:8193", "R999", FocasDataType.Byte)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => + { + var c = new FakeFocasClient(); + c.ReadStatuses["R999"] = FocasStatusMapper.BadNodeIdUnknown; + return c; + }; + + var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown); + } + + [Fact] + public async Task Read_exception_surfaces_BadCommunicationError() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeFocasClient { ThrowOnRead = true }; + + var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + } + + [Fact] + public async Task Connect_failure_disposes_client_and_surfaces_BadCommunicationError() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeFocasClient { ThrowOnConnect = true }; + + var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); + factory.Clients[0].DisposeCount.ShouldBe(1); + } + + [Fact] + public async Task Batched_reads_preserve_order_across_areas() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte), + new FocasTagDefinition("B", "focas://10.0.0.5:8193", "PARAM:1820", FocasDataType.Int32), + new FocasTagDefinition("C", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeFocasClient + { + Values = + { + ["R100"] = (sbyte)5, + ["PARAM:1820"] = 1500, + ["MACRO:500"] = 2.718, + }, + }; + + var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); + snapshots[0].Value.ShouldBe((sbyte)5); + snapshots[1].Value.ShouldBe(1500); + snapshots[2].Value.ShouldBe(2.718); + } + + // ---- Write ---- + + [Fact] + public async Task Non_writable_tag_rejected_with_BadNotWritable() + { + var (drv, _) = NewDriver( + new FocasTagDefinition("RO", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: false)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync( + [new WriteRequest("RO", 1)], CancellationToken.None); + results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable); + } + + [Fact] + public async Task Successful_write_logs_address_type_value() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("Speed", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync( + [new WriteRequest("Speed", (short)1800)], CancellationToken.None); + + results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); + var write = factory.Clients[0].WriteLog.Single(); + write.addr.Canonical.ShouldBe("R100"); + write.type.ShouldBe(FocasDataType.Int16); + write.value.ShouldBe((short)1800); + } + + [Fact] + public async Task Write_status_code_maps_via_FocasStatusMapper() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("Protected", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => + { + var c = new FakeFocasClient(); + c.WriteStatuses["R100"] = FocasStatusMapper.BadNotWritable; + return c; + }; + + var results = await drv.WriteAsync( + [new WriteRequest("Protected", (sbyte)1)], CancellationToken.None); + results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable); + } + + [Fact] + public async Task Batch_write_preserves_order_across_outcomes() + { + var factory = new FakeFocasClientFactory(); + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], + Tags = + [ + new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte), + new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false), + ], + Probe = new FocasProbeOptions { Enabled = false }, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync( + [ + new WriteRequest("A", (sbyte)1), + new WriteRequest("B", (sbyte)2), + new WriteRequest("Unknown", (sbyte)3), + ], CancellationToken.None); + + results[0].StatusCode.ShouldBe(FocasStatusMapper.Good); + results[1].StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable); + results[2].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown); + } + + [Fact] + public async Task Cancellation_propagates() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeFocasClient + { + ThrowOnRead = true, + Exception = new OperationCanceledException(), + }; + + await Should.ThrowAsync( + () => drv.ReadAsync(["X"], CancellationToken.None)); + } + + [Fact] + public async Task ShutdownAsync_disposes_client() + { + var (drv, factory) = NewDriver( + new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } }; + + await drv.ReadAsync(["X"], CancellationToken.None); + await drv.ShutdownAsync(CancellationToken.None); + + factory.Clients[0].DisposeCount.ShouldBe(1); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FwlibNativeHelperTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FwlibNativeHelperTests.cs new file mode 100644 index 0000000..3593d9b --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FwlibNativeHelperTests.cs @@ -0,0 +1,100 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// Tests for the managed helpers inside FwlibNative + FwlibFocasClient that don't require the +/// licensed Fwlib32.dll — letter→ADR_* mapping, FocasDataType→data-type mapping, byte encoding. +/// The actual P/Invoke calls can only run where the DLL is present; field testing covers those. +/// +[Trait("Category", "Unit")] +public sealed class FwlibNativeHelperTests +{ + [Theory] + [InlineData("G", 0)] + [InlineData("F", 1)] + [InlineData("Y", 2)] + [InlineData("X", 3)] + [InlineData("A", 4)] + [InlineData("R", 5)] + [InlineData("T", 6)] + [InlineData("K", 7)] + [InlineData("C", 8)] + [InlineData("D", 9)] + [InlineData("E", 10)] + [InlineData("g", 0)] // case-insensitive + public void PmcAddrType_maps_every_valid_letter(string letter, short expected) + { + FocasPmcAddrType.FromLetter(letter).ShouldBe(expected); + } + + [Theory] + [InlineData("Z")] + [InlineData("")] + [InlineData("XX")] + public void PmcAddrType_rejects_unknown_letters(string letter) + { + FocasPmcAddrType.FromLetter(letter).ShouldBeNull(); + } + + [Theory] + [InlineData(FocasDataType.Bit, 0)] // byte + [InlineData(FocasDataType.Byte, 0)] + [InlineData(FocasDataType.Int16, 1)] // word + [InlineData(FocasDataType.Int32, 2)] // long + [InlineData(FocasDataType.Float32, 4)] + [InlineData(FocasDataType.Float64, 5)] + public void PmcDataType_maps_FocasDataType_to_FOCAS_code(FocasDataType input, short expected) + { + FocasPmcDataType.FromFocasDataType(input).ShouldBe(expected); + } + + [Fact] + public void EncodePmcValue_Byte_writes_signed_byte_at_offset_0() + { + var buf = new byte[40]; + FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Byte, (sbyte)-5, bitIndex: null); + ((sbyte)buf[0]).ShouldBe((sbyte)-5); + } + + [Fact] + public void EncodePmcValue_Int16_writes_little_endian() + { + var buf = new byte[40]; + FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Int16, (short)0x1234, bitIndex: null); + buf[0].ShouldBe((byte)0x34); + buf[1].ShouldBe((byte)0x12); + } + + [Fact] + public void EncodePmcValue_Int32_writes_little_endian() + { + var buf = new byte[40]; + FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Int32, 0x12345678, bitIndex: null); + buf[0].ShouldBe((byte)0x78); + buf[1].ShouldBe((byte)0x56); + buf[2].ShouldBe((byte)0x34); + buf[3].ShouldBe((byte)0x12); + } + + [Fact] + public void EncodePmcValue_Bit_throws_NotSupported_for_RMW_gap() + { + var buf = new byte[40]; + Should.Throw(() => + FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Bit, true, bitIndex: 3)); + } + + [Fact] + public void EncodeParamValue_Int32_writes_little_endian() + { + var buf = new byte[32]; + FwlibFocasClient.EncodeParamValue(buf, FocasDataType.Int32, 0x0A0B0C0D); + buf[0].ShouldBe((byte)0x0D); + buf[1].ShouldBe((byte)0x0C); + buf[2].ShouldBe((byte)0x0B); + buf[3].ShouldBe((byte)0x0A); + } +}