From a2c7fda5f5e20085457873bc006ce3c1e1b2b468 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 19:55:37 -0400 Subject: [PATCH] =?UTF-8?q?FOCAS=20PR=202=20=E2=80=94=20IReadable=20+=20IW?= =?UTF-8?q?ritable=20+=20real=20FwlibFocasClient=20P/Invoke.=20Closes=20ta?= =?UTF-8?q?sk=20#193=20early=20now=20that=20strangesast/fwlib=20provides?= =?UTF-8?q?=20the=20licensed=20DLL=20references.=20Skips=20shipping=20with?= =?UTF-8?q?=20the=20Unimplemented=20stub=20as=20the=20default=20=E2=80=94?= =?UTF-8?q?=20FwlibFocasClientFactory=20is=20now=20the=20production=20defa?= =?UTF-8?q?ult,=20UnimplementedFocasClientFactory=20stays=20as=20an=20opt-?= =?UTF-8?q?in=20for=20tests/deployments=20without=20FWLIB=20access.=20Fwli?= =?UTF-8?q?bNative=20=E2=80=94=20narrow=20P/Invoke=20surface=20for=20the?= =?UTF-8?q?=207=20calls=20the=20driver=20actually=20makes:=20cnc=5Fallclib?= =?UTF-8?q?hndl3=20(open=20Ethernet=20handle),=20cnc=5Ffreelibhndl=20(clos?= =?UTF-8?q?e),=20pmc=5Frdpmcrng=20+=20pmc=5Fwrpmcrng=20(PMC=20range=20I/O)?= =?UTF-8?q?,=20cnc=5Frdparam=20+=20cnc=5Fwrparam=20(CNC=20parameters),=20c?= =?UTF-8?q?nc=5Frdmacro=20+=20cnc=5Fwrmacro=20(macro=20variables),=20cnc?= =?UTF-8?q?=5Fstatinfo=20(probe).=20DllImport=20targets=20Fwlib32.dll;=20d?= =?UTF-8?q?eployment=20places=20it=20next=20to=20the=20executable=20or=20o?= =?UTF-8?q?n=20PATH.=20IODBPMC/IODBPSD/ODBM/ODBST=20marshaled=20with=20Lay?= =?UTF-8?q?outKind.Sequential=20+=20Pack=3D1=20+=20fixed=20byte-array=20un?= =?UTF-8?q?ions=20(avoids=20LayoutKind.Explicit=20complexity;=20managed-si?= =?UTF-8?q?de=20BitConverter=20extracts=20typed=20values=20from=20the=20by?= =?UTF-8?q?te=20buffer).=20Internal=20helpers=20FocasPmcAddrType.FromLette?= =?UTF-8?q?r=20(G=3D0/F=3D1/Y=3D2/X=3D3/A=3D4/R=3D5/T=3D6/K=3D7/C=3D8/D=3D?= =?UTF-8?q?9/E=3D10=20per=20Fanuc=20FOCAS/2=20spec)=20+=20FocasPmcDataType?= =?UTF-8?q?.FromFocasDataType=20(Byte=3D0=20/=20Word=3D1=20/=20Long=3D2=20?= =?UTF-8?q?/=20Float=3D4=20/=20Double=3D5)=20exposed=20for=20testing=20wit?= =?UTF-8?q?hout=20the=20DLL=20loaded.=20FwlibFocasClient=20is=20the=20conc?= =?UTF-8?q?rete=20IFocasClient=20backed=20by=20P/Invoke.=20Construction=20?= =?UTF-8?q?is=20licence-safe=20=E2=80=94=20.NET=20P/Invoke=20is=20lazy=20s?= =?UTF-8?q?o=20instantiating=20the=20class=20does=20NOT=20load=20Fwlib32.d?= =?UTF-8?q?ll;=20DLL=20loads=20on=20first=20wire=20call=20(Connect/Read/Wr?= =?UTF-8?q?ite/Probe).=20When=20missing,=20calls=20throw=20DllNotFoundExce?= =?UTF-8?q?ption=20which=20the=20driver=20surfaces=20as=20BadCommunication?= =?UTF-8?q?Error=20via=20the=20normal=20exception=20path.=20Session-scoped?= =?UTF-8?q?=20handle=20from=20cnc=5Fallclibhndl3;=20Dispose=20calls=20cnc?= =?UTF-8?q?=5Ffreelibhndl.=20Dispatch=20on=20FocasAreaKind=20=E2=80=94=20P?= =?UTF-8?q?mc=20reads=20use=20pmc=5Frdpmcrng=20with=20the=20right=20ADR=5F?= =?UTF-8?q?*=20+=20data-type=20codes=20+=20parses=20the=20union=20via=20Bi?= =?UTF-8?q?naryPrimitives=20LittleEndian,=20Parameter=20reads=20use=20cnc?= =?UTF-8?q?=5Frdparam=20+=20IODBPSD,=20Macro=20reads=20use=20cnc=5Frdmacro?= =?UTF-8?q?=20+=20compute=20scaled=20double=20as=20McrVal=20/=2010^DecVal.?= =?UTF-8?q?=20Write=20paths=20mirror=20reads.=20PMC=20Bit=20writes=20throw?= =?UTF-8?q?=20NotSupportedException=20pointing=20at=20task=20#181=20(read-?= =?UTF-8?q?modify-write=20gap=20=E2=80=94=20same=20as=20Modbus=20/=20AbCip?= =?UTF-8?q?=20/=20AbLegacy=20/=20TwinCAT).=20Macro=20writes=20accept=20int?= =?UTF-8?q?=20+=20pass=20decimal-point=20count=200=20(decimal=20precision?= =?UTF-8?q?=20writes=20are=20a=20future=20enhancement).=20Probe=20calls=20?= =?UTF-8?q?cnc=5Fstatinfo=20with=20ODBST=20result.=20Driver=20wiring=20?= =?UTF-8?q?=E2=80=94=20FocasDriver=20now=20IDriver=20+=20IReadable=20+=20I?= =?UTF-8?q?Writable.=20Per-device=20connection=20caching=20via=20EnsureCon?= =?UTF-8?q?nectedAsync=20+=20DeviceState.Client.=20ReadAsync/WriteAsync=20?= =?UTF-8?q?dispatch=20through=20the=20injected=20IFocasClient=20=E2=80=94?= =?UTF-8?q?=20ordered=20snapshots=20preserve=20per-tag=20status,=20Operati?= =?UTF-8?q?onCanceledException=20rethrows,=20FormatException/InvalidCastEx?= =?UTF-8?q?ception=20=E2=86=92=20BadTypeMismatch,=20OverflowException=20?= =?UTF-8?q?=E2=86=92=20BadOutOfRange,=20NotSupportedException=20=E2=86=92?= =?UTF-8?q?=20BadNotSupported,=20anything=20else=20=E2=86=92=20BadCommunic?= =?UTF-8?q?ationError=20+=20Degraded=20health.=20Connect-failure=20dispose?= =?UTF-8?q?s=20the=20half-open=20client.=20ShutdownAsync=20disposes=20ever?= =?UTF-8?q?y=20cached=20client.=20Default=20factory=20switched=20=E2=80=94?= =?UTF-8?q?=20constructor=20now=20defaults=20to=20FwlibFocasClientFactory?= =?UTF-8?q?=20(backed=20by=20real=20Fwlib32.dll)=20rather=20than=20Unimple?= =?UTF-8?q?mentedFocasClientFactory.=20UnimplementedFocasClientFactory=20s?= =?UTF-8?q?tays=20as=20an=20opt-in.=2041=20new=20tests=20=E2=80=94=2014=20?= =?UTF-8?q?in=20FocasReadWriteTests=20(ordered=20unknown-ref=20handling,?= =?UTF-8?q?=20successful=20PMC/Parameter/Macro=20reads=20routing=20through?= =?UTF-8?q?=20correct=20FocasAreaKind,=20repeat-read=20reuses=20connection?= =?UTF-8?q?,=20FOCAS=20error=20mapping,=20exception=20paths,=20batched=20o?= =?UTF-8?q?rder=20across=20areas,=20non-writable=20rejection,=20successful?= =?UTF-8?q?=20write=20logging,=20status=20mapping,=20batch=20ordering,=20c?= =?UTF-8?q?ancellation,=20shutdown=20disposes),=2027=20in=20FwlibNativeHel?= =?UTF-8?q?perTests=20(12=20letter-mapping=20cases=20+=203=20unknown=20rej?= =?UTF-8?q?ections=20+=206=20data-type=20mapping=20+=204=20encode=20helper?= =?UTF-8?q?s=20+=20Bit-write=20NotSupported).=20Total=20FOCAS=20unit=20tes?= =?UTF-8?q?ts=20now=20106/106=20passing=20(+41=20from=20PR=201's=2065);=20?= =?UTF-8?q?full=20solution=20builds=200=20errors;=20Modbus=20/=20AbCip=20/?= =?UTF-8?q?=20AbLegacy=20/=20TwinCAT=20/=20other=20drivers=20untouched.=20?= =?UTF-8?q?FOCAS=20driver=20is=20real-wire-capable=20from=20day=20one=20?= =?UTF-8?q?=E2=80=94=20deployment=20drops=20Fwlib32.dll=20beside=20the=20s?= =?UTF-8?q?erver=20+=20driver=20talks=20to=20live=20FS=200i/16i/18i/21i/30?= =?UTF-8?q?i/31i/32i=20controllers.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../FocasDriver.cs | 139 ++++++++- .../FwlibFocasClient.cs | 269 ++++++++++++++++++ .../FwlibNative.cs | 190 +++++++++++++ .../FakeFocasClient.cs | 69 +++++ .../FocasReadWriteTests.cs | 261 +++++++++++++++++ .../FwlibNativeHelperTests.cs | 100 +++++++ 6 files changed, 1026 insertions(+), 2 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadWriteTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FwlibNativeHelperTests.cs 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); + } +}