FOCAS PR 2 � IReadable + IWritable + real FwlibFocasClient P/Invoke #125

Merged
dohertj2 merged 1 commits from focas-pr2-read-write-real-client into v2 2026-04-19 19:57:33 -04:00
6 changed files with 1026 additions and 2 deletions

View File

@@ -15,12 +15,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// + the default <see cref="UnimplementedFocasClientFactory"/> makes misconfigured servers
/// fail fast.
/// </remarks>
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<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, FocasTagDefinition> _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<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> 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<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> 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<IFocasClient> 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;
}
}
}

View File

@@ -0,0 +1,269 @@
using System.Buffers.Binary;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// <see cref="IFocasClient"/> implementation backed by Fanuc's licensed
/// <c>Fwlib32.dll</c> via <see cref="FwlibNative"/> P/Invoke. The DLL is NOT shipped with
/// OtOpcUa; the deployment places it next to the server executable or on <c>PATH</c>
/// (per Fanuc licensing — see <c>docs/v2/focas-deployment.md</c>).
/// </summary>
/// <remarks>
/// <para>Construction is licence-safe — .NET P/Invoke is lazy, so instantiating this class
/// does NOT load <c>Fwlib32.dll</c>. The DLL only loads on the first wire call (Connect /
/// Read / Write / Probe). When missing, those calls throw <see cref="DllNotFoundException"/>
/// which the driver surfaces as <c>BadCommunicationError</c> through the normal exception
/// mapping.</para>
///
/// <para>Session-scoped handle — <c>cnc_allclibhndl3</c> opens one FWLIB handle per CNC;
/// all PMC / parameter / macro reads on that device go through the same handle. Dispose
/// calls <c>cnc_freelibhndl</c>.</para>
/// </remarks>
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<uint> 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<bool> 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;
}
}
}
/// <summary>Default <see cref="IFocasClientFactory"/> — produces a fresh <see cref="FwlibFocasClient"/> per device.</summary>
public sealed class FwlibFocasClientFactory : IFocasClientFactory
{
public IFocasClient Create() => new FwlibFocasClient();
}

View File

@@ -0,0 +1,190 @@
using System.Runtime.InteropServices;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// P/Invoke surface for Fanuc FWLIB (<c>Fwlib32.dll</c>). Declarations extracted from
/// <c>fwlib32.h</c> in the strangesast/fwlib repo; the licensed DLL itself is NOT shipped
/// with OtOpcUa — the deployment places <c>Fwlib32.dll</c> next to the server executable
/// or on <c>PATH</c>.
/// </summary>
/// <remarks>
/// Deliberately narrow — only the calls <see cref="FwlibFocasClient"/> actually makes.
/// FOCAS has 800+ functions in <c>fwlib32.h</c>; pulling in every one would bloat the
/// P/Invoke surface + signal more coverage than this driver provides. Expand as capabilities
/// are added.
/// </remarks>
internal static class FwlibNative
{
private const string Library = "Fwlib32.dll";
// ---- Handle lifetime ----
/// <summary>Open an Ethernet FWLIB handle. Returns EW_OK (0) on success; handle written out.</summary>
[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 ----
/// <summary>PMC range read. <paramref name="addrType"/> is the ADR_* enum; <paramref name="dataType"/> is 0 byte / 1 word / 2 long.</summary>
[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 ----
/// <summary>
/// IODBPMC — PMC range I/O buffer. 8-byte header + 40-byte union. We marshal the union
/// as a fixed byte buffer + interpret per <see cref="FocasDataType"/> on the managed side.
/// </summary>
[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;
}
/// <summary>
/// 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.
/// </summary>
[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;
}
/// <summary>ODBM — macro variable read buffer. Value = <c>McrVal / 10^DecVal</c>.</summary>
[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
}
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
[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;
}
}
/// <summary>
/// PMC address-letter → FOCAS <c>ADR_*</c> 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.
/// </summary>
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,
};
}
/// <summary>PMC data-type numeric codes per FOCAS/2: 0 = byte, 1 = word, 2 = long, 4 = float, 5 = double.</summary>
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,
};
}

View File

@@ -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<string, object?> Values { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, uint> 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<uint> 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<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
public virtual void Dispose()
{
DisposeCount++;
IsConnected = false;
}
}
internal sealed class FakeFocasClientFactory : IFocasClientFactory
{
public List<FakeFocasClient> Clients { get; } = new();
public Func<FakeFocasClient>? Customise { get; set; }
public IFocasClient Create()
{
var c = Customise?.Invoke() ?? new FakeFocasClient();
Clients.Add(c);
return c;
}
}

View File

@@ -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<OperationCanceledException>(
() => 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);
}
}

View File

@@ -0,0 +1,100 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// 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.
/// </summary>
[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<NotSupportedException>(() =>
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);
}
}