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);
+ }
+}