Compare commits

..

4 Commits

Author SHA1 Message Date
Joseph Doherty
a2c7fda5f5 FOCAS PR 2 — IReadable + IWritable + real FwlibFocasClient P/Invoke. Closes task #193 early now that strangesast/fwlib provides the licensed DLL references. Skips shipping with the Unimplemented stub as the default — FwlibFocasClientFactory is now the production default, UnimplementedFocasClientFactory stays as an opt-in for tests/deployments without FWLIB access. FwlibNative — narrow P/Invoke surface for the 7 calls the driver actually makes: cnc_allclibhndl3 (open Ethernet handle), cnc_freelibhndl (close), pmc_rdpmcrng + pmc_wrpmcrng (PMC range I/O), cnc_rdparam + cnc_wrparam (CNC parameters), cnc_rdmacro + cnc_wrmacro (macro variables), cnc_statinfo (probe). DllImport targets Fwlib32.dll; deployment places it next to the executable or on PATH. IODBPMC/IODBPSD/ODBM/ODBST marshaled with LayoutKind.Sequential + Pack=1 + fixed byte-array unions (avoids LayoutKind.Explicit complexity; managed-side BitConverter extracts typed values from the byte buffer). Internal helpers FocasPmcAddrType.FromLetter (G=0/F=1/Y=2/X=3/A=4/R=5/T=6/K=7/C=8/D=9/E=10 per Fanuc FOCAS/2 spec) + FocasPmcDataType.FromFocasDataType (Byte=0 / Word=1 / Long=2 / Float=4 / Double=5) exposed for testing without the DLL loaded. FwlibFocasClient is the concrete IFocasClient backed by P/Invoke. Construction is licence-safe — .NET P/Invoke is lazy so instantiating the class does NOT load Fwlib32.dll; DLL loads on first wire call (Connect/Read/Write/Probe). When missing, calls throw DllNotFoundException which the driver surfaces as BadCommunicationError via the normal exception path. Session-scoped handle from cnc_allclibhndl3; Dispose calls cnc_freelibhndl. Dispatch on FocasAreaKind — Pmc reads use pmc_rdpmcrng with the right ADR_* + data-type codes + parses the union via BinaryPrimitives LittleEndian, Parameter reads use cnc_rdparam + IODBPSD, Macro reads use cnc_rdmacro + compute scaled double as McrVal / 10^DecVal. Write paths mirror reads. PMC Bit writes throw NotSupportedException pointing at task #181 (read-modify-write gap — same as Modbus / AbCip / AbLegacy / TwinCAT). Macro writes accept int + pass decimal-point count 0 (decimal precision writes are a future enhancement). Probe calls cnc_statinfo with ODBST result. Driver wiring — FocasDriver now IDriver + IReadable + IWritable. Per-device connection caching via EnsureConnectedAsync + DeviceState.Client. ReadAsync/WriteAsync dispatch through the injected IFocasClient — ordered snapshots preserve per-tag status, OperationCanceledException rethrows, FormatException/InvalidCastException → BadTypeMismatch, OverflowException → BadOutOfRange, NotSupportedException → BadNotSupported, anything else → BadCommunicationError + Degraded health. Connect-failure disposes the half-open client. ShutdownAsync disposes every cached client. Default factory switched — constructor now defaults to FwlibFocasClientFactory (backed by real Fwlib32.dll) rather than UnimplementedFocasClientFactory. UnimplementedFocasClientFactory stays as an opt-in. 41 new tests — 14 in FocasReadWriteTests (ordered unknown-ref handling, successful PMC/Parameter/Macro reads routing through correct FocasAreaKind, repeat-read reuses connection, FOCAS error mapping, exception paths, batched order across areas, non-writable rejection, successful write logging, status mapping, batch ordering, cancellation, shutdown disposes), 27 in FwlibNativeHelperTests (12 letter-mapping cases + 3 unknown rejections + 6 data-type mapping + 4 encode helpers + Bit-write NotSupported). Total FOCAS unit tests now 106/106 passing (+41 from PR 1's 65); full solution builds 0 errors; Modbus / AbCip / AbLegacy / TwinCAT / other drivers untouched. FOCAS driver is real-wire-capable from day one — deployment drops Fwlib32.dll beside the server + driver talks to live FS 0i/16i/18i/21i/30i/31i/32i controllers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 19:55:37 -04:00
c13fe8f587 Merge pull request (#124) - FOCAS scaffolding 2026-04-19 19:49:47 -04:00
Joseph Doherty
285799a954 FOCAS PR 1 — Scaffolding + Core (FocasDriver skeleton + address parser + stub client). New Driver.FOCAS project for Fanuc CNC controllers (FS 0i/16i/18i/21i/30i/31i/32i/Series 35i/Power Mate i) talking via the Fanuc FOCAS/2 protocol. No NuGet reference to a FOCAS library — FWLIB (Fwlib32.dll) is Fanuc-proprietary + per-customer licensed + cannot be legally redistributed, so the driver is designed from the start to accept an IFocasClient supplied by the deployment side. Default IFocasClientFactory is UnimplementedFocasClientFactory which throws with a clear deployment-docs pointer at Create time so misconfigured servers fail fast rather than mysteriously hanging. Matches the pattern other drivers use for swappable wire layers (Modbus IModbusTransport, AbCip IAbCipTagFactory, TwinCAT ITwinCATClientFactory) — but uniquely, FOCAS ships without a production factory because of licensing. FocasHostAddress parses focas://{host}[:{port}] canonical form with default port 8193 (Fanuc-reserved FOCAS Ethernet port). Default-port stripping on ToString for roundtrip stability. Case-insensitive scheme. Rejects wrong scheme, empty body, invalid port, non-numeric port. FocasAddress handles the three addressing spaces a FOCAS driver touches — PMC (letter + byte + optional bit, X/Y for IO, F/G for PMC-CNC signals, R for internal relay, D for data table, C for counter, K for keep relay, A for message display, E for extended relay, T for timer, with .N bit syntax 0-7), CNC parameters (PARAM:n for a parameter number, PARAM:n/N for bit 0-31 of a parameter), macro variables (MACRO:n). Rejects unknown PMC letters, negative numbers, out-of-range bits (PMC 0-7, parameter 0-31), non-numeric fragments. FocasDataType — Bit / Byte / Int16 / Int32 / Float32 / Float64 / String covering the atomic types PMC reads + CNC parameters + macro variables return. ToDriverDataType widens to the Int32/Float32/Float64/Boolean/String surface. FocasStatusMapper covers the FWLIB EW_* return-code family documented in the FOCAS/1 + FOCAS/2 references — EW_OK=0, EW_FUNC=1 → BadNotSupported, EW_OVRFLOW=2/EW_NUMBER=3/EW_LENGTH=4 → BadOutOfRange, EW_PROT=5/EW_PASSWD=11 → BadNotWritable, EW_NOOPT=6/EW_VERSION=-9 → BadNotSupported, EW_ATTRIB=7 → BadTypeMismatch, EW_DATA=8 → BadNodeIdUnknown, EW_PARITY=9 → BadCommunicationError, EW_BUSY=-1 → BadDeviceFailure, EW_HANDLE=-8 → BadInternalError, EW_UNEXP=-10/EW_SOCKET=-16 → BadCommunicationError. IFocasClient + IFocasClientFactory abstraction — ConnectAsync, IsConnected, ReadAsync returning (value, status) tuple, WriteAsync returning status, ProbeAsync for IHostConnectivityProbe. Deployment supplies the real factory; driver assembly stays licence-clean. FocasDriverOptions + FocasDeviceOptions + FocasTagDefinition + FocasProbeOptions — one instance supports N CNCs, tags cross-key by HostAddress + use canonical FocasAddress strings. FocasDriver implements IDriver only (PRs 2-3 add read/write/discover/subscribe/probe/resolver). InitializeAsync parses each device HostAddress + fails fast on malformed strings → Faulted health. 65 new unit tests in FocasScaffoldingTests covering — 5 valid host forms + 8 invalid + default-port-strip ToString, 12 valid PMC addresses across all 11 canonical letters + 3 parameter forms with + without bit + 2 macro forms, 10 invalid address shapes, canonical roundtrip theory, data-type mapping theory, FWLIB EW_* status mapping theory (9 codes + unknown → generic), DriverType, multi-device Initialize + address parsing, malformed-address fault, shutdown, default factory throws NotSupportedException with deployment pointer + Fwlib32.dll mention. Total project count 31 src + 20 tests; full solution builds 0 errors. Other drivers untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 19:47:52 -04:00
9da578d5a5 Merge pull request (#123) - TwinCAT native notifications 2026-04-19 18:51:39 -04:00
16 changed files with 1737 additions and 0 deletions

View File

@@ -13,6 +13,7 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
@@ -35,6 +36,7 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>

View File

@@ -0,0 +1,95 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Parsed FOCAS address covering the three addressing spaces a driver touches:
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), and <see cref="FocasAreaKind.Macro"/>
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>).
/// </summary>
/// <remarks>
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
/// bit index when present is 07 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
/// </remarks>
public sealed record FocasAddress(
FocasAreaKind Kind,
string? PmcLetter,
int Number,
int? BitIndex)
{
public string Canonical => Kind switch
{
FocasAreaKind.Pmc => BitIndex is null
? $"{PmcLetter}{Number}"
: $"{PmcLetter}{Number}.{BitIndex}",
FocasAreaKind.Parameter => BitIndex is null
? $"PARAM:{Number}"
: $"PARAM:{Number}/{BitIndex}",
FocasAreaKind.Macro => $"MACRO:{Number}",
_ => $"?{Number}",
};
public static FocasAddress? TryParse(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var src = value.Trim();
if (src.StartsWith("PARAM:", StringComparison.OrdinalIgnoreCase))
return ParseScoped(src["PARAM:".Length..], FocasAreaKind.Parameter, bitSeparator: '/');
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
// PMC path: letter + digits + optional .bit
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
var letter = src[0..1].ToUpperInvariant();
if (!IsValidPmcLetter(letter)) return null;
var remainder = src[1..];
int? bit = null;
var dotIdx = remainder.IndexOf('.');
if (dotIdx >= 0)
{
if (!int.TryParse(remainder[(dotIdx + 1)..], out var bitValue) || bitValue is < 0 or > 7)
return null;
bit = bitValue;
remainder = remainder[..dotIdx];
}
if (!int.TryParse(remainder, out var number) || number < 0) return null;
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit);
}
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
{
int? bit = null;
if (bitSeparator is char sep)
{
var slashIdx = body.IndexOf(sep);
if (slashIdx >= 0)
{
if (!int.TryParse(body[(slashIdx + 1)..], out var bitValue) || bitValue is < 0 or > 31)
return null;
bit = bitValue;
body = body[..slashIdx];
}
}
if (!int.TryParse(body, out var number) || number < 0) return null;
return new FocasAddress(kind, PmcLetter: null, number, bit);
}
private static bool IsValidPmcLetter(string letter) => letter switch
{
"X" or "Y" or "F" or "G" or "R" or "D" or "C" or "K" or "A" or "E" or "T" => true,
_ => false,
};
}
/// <summary>Addressing-space kinds the driver understands.</summary>
public enum FocasAreaKind
{
Pmc,
Parameter,
Macro,
}

View File

@@ -0,0 +1,39 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// FOCAS atomic data types. Narrower than Logix/IEC — FANUC CNCs expose mostly integer +
/// floating-point data with no UDT concept; macro variables are double-precision floats
/// and PMC reads return byte / signed word / signed dword.
/// </summary>
public enum FocasDataType
{
/// <summary>Single bit (PMC bit, or bit within a CNC parameter).</summary>
Bit,
/// <summary>8-bit signed byte (PMC 1-byte read).</summary>
Byte,
/// <summary>16-bit signed word (PMC 2-byte read, or CNC parameter as short).</summary>
Int16,
/// <summary>32-bit signed int (PMC 4-byte read, or CNC parameter as int).</summary>
Int32,
/// <summary>32-bit IEEE-754 float (rare; some CNC macro variables).</summary>
Float32,
/// <summary>64-bit IEEE-754 double (most macro variables are double-precision).</summary>
Float64,
/// <summary>ASCII string (alarm text, parameter names, some PMC string areas).</summary>
String,
}
public static class FocasDataTypeExtensions
{
public static DriverDataType ToDriverDataType(this FocasDataType t) => t switch
{
FocasDataType.Bit => DriverDataType.Boolean,
FocasDataType.Byte or FocasDataType.Int16 or FocasDataType.Int32 => DriverDataType.Int32,
FocasDataType.Float32 => DriverDataType.Float32,
FocasDataType.Float64 => DriverDataType.Float64,
FocasDataType.String => DriverDataType.String,
_ => DriverDataType.Int32,
};
}

View File

@@ -0,0 +1,224 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// FOCAS driver for Fanuc CNC controllers (FS 0i / 16i / 18i / 21i / 30i / 31i / 32i / Series
/// 35i / Power Mate i). Talks to the CNC via the Fanuc FOCAS/2 FWLIB protocol through an
/// <see cref="IFocasClient"/> the deployment supplies — FWLIB itself is Fanuc-proprietary
/// and cannot be redistributed.
/// </summary>
/// <remarks>
/// PR 1 ships <see cref="IDriver"/> only; read / write / discover / subscribe / probe / host-
/// resolver capabilities land in PRs 2 and 3. The <see cref="IFocasClient"/> abstraction
/// shipped here lets PR 2 onward stay license-clean — all tests run against a fake client
/// + the default <see cref="UnimplementedFocasClientFactory"/> makes misconfigured servers
/// fail fast.
/// </remarks>
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,
IFocasClientFactory? clientFactory = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_clientFactory = clientFactory ?? new FwlibFocasClientFactory();
}
public string DriverInstanceId => _driverInstanceId;
public string DriverType => "FOCAS";
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
foreach (var device in _options.Devices)
{
var addr = FocasHostAddress.TryParse(device.HostAddress)
?? throw new InvalidOperationException(
$"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)
{
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
throw;
}
return Task.CompletedTask;
}
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
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;
}
public DriverHealth GetHealth() => _health;
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
internal int DeviceCount => _devices.Count;
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);
internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options)
{
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,38 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// FOCAS driver configuration. One instance supports N CNC devices. Per plan decision #144
/// each device gets its own <c>(DriverInstanceId, HostAddress)</c> bulkhead key at the
/// Phase 6.1 resilience layer.
/// </summary>
public sealed class FocasDriverOptions
{
public IReadOnlyList<FocasDeviceOptions> Devices { get; init; } = [];
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
public FocasProbeOptions Probe { get; init; } = new();
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
}
public sealed record FocasDeviceOptions(
string HostAddress,
string? DeviceName = null);
/// <summary>
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
/// address string that parses via <see cref="FocasAddress.TryParse"/> —
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c>.
/// </summary>
public sealed record FocasTagDefinition(
string Name,
string DeviceHostAddress,
string Address,
FocasDataType DataType,
bool Writable = true,
bool WriteIdempotent = false);
public sealed class FocasProbeOptions
{
public bool Enabled { get; init; } = true;
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
}

View File

@@ -0,0 +1,41 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Parsed FOCAS target address — IP + TCP port. Canonical <c>focas://{ip}[:{port}]</c>.
/// Default port 8193 (Fanuc-reserved FOCAS Ethernet port).
/// </summary>
public sealed record FocasHostAddress(string Host, int Port)
{
/// <summary>Fanuc-reserved TCP port for FOCAS Ethernet.</summary>
public const int DefaultPort = 8193;
public override string ToString() => Port == DefaultPort
? $"focas://{Host}"
: $"focas://{Host}:{Port}";
public static FocasHostAddress? TryParse(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
const string prefix = "focas://";
if (!value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null;
var body = value[prefix.Length..];
if (string.IsNullOrEmpty(body)) return null;
var colonIdx = body.LastIndexOf(':');
string host;
var port = DefaultPort;
if (colonIdx >= 0)
{
host = body[..colonIdx];
if (!int.TryParse(body[(colonIdx + 1)..], out port) || port is <= 0 or > 65535)
return null;
}
else
{
host = body;
}
if (string.IsNullOrEmpty(host)) return null;
return new FocasHostAddress(host, port);
}
}

View File

@@ -0,0 +1,48 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Maps FOCAS / FWLIB return codes to OPC UA StatusCodes. The FWLIB C API uses an
/// <c>EW_*</c> constant family per the Fanuc FOCAS/1 and FOCAS/2 documentation
/// (<c>EW_OK = 0</c>, <c>EW_NUMBER</c>, <c>EW_SOCKET</c>, etc.). Mirrors the shape of the
/// AbCip / TwinCAT mappers so Admin UI status displays stay uniform across drivers.
/// </summary>
public static class FocasStatusMapper
{
public const uint Good = 0u;
public const uint BadInternalError = 0x80020000u;
public const uint BadNodeIdUnknown = 0x80340000u;
public const uint BadNotWritable = 0x803B0000u;
public const uint BadOutOfRange = 0x803C0000u;
public const uint BadNotSupported = 0x803D0000u;
public const uint BadDeviceFailure = 0x80550000u;
public const uint BadCommunicationError = 0x80050000u;
public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80730000u;
/// <summary>
/// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
/// numeric conventions (EW_OK=0, EW_FUNC=1, EW_NUMBER=3, EW_LENGTH=4, EW_ATTRIB=7,
/// EW_DATA=8, EW_NOOPT=6, EW_PROT=5, EW_OVRFLOW=2, EW_PARITY=9, EW_PASSWD=11,
/// EW_BUSY=-1, EW_HANDLE=-8, EW_VERSION=-9, EW_UNEXP=-10, EW_SOCKET=-16).
/// </summary>
public static uint MapFocasReturn(int ret) => ret switch
{
0 => Good,
1 => BadNotSupported, // EW_FUNC — CNC does not support this function
2 => BadOutOfRange, // EW_OVRFLOW
3 => BadOutOfRange, // EW_NUMBER
4 => BadOutOfRange, // EW_LENGTH
5 => BadNotWritable, // EW_PROT
6 => BadNotSupported, // EW_NOOPT — optional CNC feature missing
7 => BadTypeMismatch, // EW_ATTRIB
8 => BadNodeIdUnknown, // EW_DATA — invalid data address
9 => BadCommunicationError, // EW_PARITY
11 => BadNotWritable, // EW_PASSWD
-1 => BadDeviceFailure, // EW_BUSY
-8 => BadInternalError, // EW_HANDLE — CNC handle not available
-9 => BadNotSupported, // EW_VERSION — FWLIB vs CNC version mismatch
-10 => BadCommunicationError, // EW_UNEXP
-16 => BadCommunicationError, // EW_SOCKET
_ => BadCommunicationError,
};
}

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,70 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Wire-layer abstraction over one FOCAS session to a CNC. The driver holds one per
/// configured device; lifetime matches the device.
/// </summary>
/// <remarks>
/// <para><b>No default wire implementation ships with this assembly.</b> FWLIB
/// (<c>Fwlib32.dll</c>) is Fanuc-proprietary and requires a valid customer license — it
/// cannot legally be redistributed. The deployment team supplies an
/// <see cref="IFocasClientFactory"/> that wraps the licensed <c>Fwlib32.dll</c> via
/// P/Invoke and registers it at server startup.</para>
///
/// <para>The default <see cref="UnimplementedFocasClientFactory"/> throws with a pointer at
/// the deployment docs so misconfigured servers fail fast with a clear error rather than
/// mysteriously hanging.</para>
/// </remarks>
public interface IFocasClient : IDisposable
{
/// <summary>Open the FWLIB handle + TCP session. Idempotent.</summary>
Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken);
/// <summary>True when the FWLIB handle is valid + the socket is up.</summary>
bool IsConnected { get; }
/// <summary>
/// Read the value at <paramref name="address"/> in the requested
/// <paramref name="type"/>. Returns a boxed .NET value + the OPC UA status mapped
/// through <see cref="FocasStatusMapper"/>.
/// </summary>
Task<(object? value, uint status)> ReadAsync(
FocasAddress address,
FocasDataType type,
CancellationToken cancellationToken);
/// <summary>
/// Write <paramref name="value"/> to <paramref name="address"/>. Returns the mapped
/// OPC UA status (0 = Good).
/// </summary>
Task<uint> WriteAsync(
FocasAddress address,
FocasDataType type,
object? value,
CancellationToken cancellationToken);
/// <summary>
/// Cheap health probe — e.g. <c>cnc_rdcncstat</c>. Returns <c>true</c> when the CNC
/// responds with any valid status.
/// </summary>
Task<bool> ProbeAsync(CancellationToken cancellationToken);
}
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
public interface IFocasClientFactory
{
IFocasClient Create();
}
/// <summary>
/// Default factory that throws at construction time — the deployment must register a real
/// factory. Keeps the driver assembly licence-clean while still allowing the skeleton to
/// compile + the abstraction tests to run.
/// </summary>
public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
{
public IFocasClient Create() => throw new NotSupportedException(
"FOCAS driver has no wire client configured. Register a real IFocasClientFactory at " +
"server startup wrapping the licensed Fwlib32.dll — see docs/v2/focas-deployment.md. " +
"Fanuc licensing forbids shipping Fwlib32.dll in the OtOpcUa package.");
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.FOCAS</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<!--
No NuGet reference to a FOCAS library — FWLIB is Fanuc-proprietary and the licensed
Fwlib32.dll cannot be redistributed. The deployment side supplies an IFocasClient
implementation that P/Invokes against whatever Fwlib32.dll the customer has licensed.
Driver.FOCAS.IntegrationTests in a separate repo can wire in the real binary.
Follow-up task #193 tracks the real-client reference implementation that customers may
drop in privately.
-->
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests"/>
</ItemGroup>
</Project>

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,228 @@
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 FocasScaffoldingTests
{
// ---- FocasHostAddress ----
[Theory]
[InlineData("focas://10.0.0.5:8193", "10.0.0.5", 8193)]
[InlineData("focas://10.0.0.5", "10.0.0.5", 8193)] // default port
[InlineData("focas://cnc-01.factory.internal:8193", "cnc-01.factory.internal", 8193)]
[InlineData("focas://10.0.0.5:12345", "10.0.0.5", 12345)]
[InlineData("FOCAS://10.0.0.5:8193", "10.0.0.5", 8193)] // case-insensitive scheme
public void HostAddress_parses_valid(string input, string host, int port)
{
var parsed = FocasHostAddress.TryParse(input);
parsed.ShouldNotBeNull();
parsed.Host.ShouldBe(host);
parsed.Port.ShouldBe(port);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("http://10.0.0.5/")]
[InlineData("focas:10.0.0.5:8193")] // missing //
[InlineData("focas://")] // empty body
[InlineData("focas://10.0.0.5:0")] // port 0
[InlineData("focas://10.0.0.5:65536")] // port out of range
[InlineData("focas://10.0.0.5:abc")] // non-numeric port
public void HostAddress_rejects_invalid(string? input)
{
FocasHostAddress.TryParse(input).ShouldBeNull();
}
[Fact]
public void HostAddress_ToString_strips_default_port()
{
new FocasHostAddress("10.0.0.5", 8193).ToString().ShouldBe("focas://10.0.0.5");
new FocasHostAddress("10.0.0.5", 12345).ToString().ShouldBe("focas://10.0.0.5:12345");
}
// ---- FocasAddress ----
[Theory]
[InlineData("X0.0", FocasAreaKind.Pmc, "X", 0, 0)]
[InlineData("X0", FocasAreaKind.Pmc, "X", 0, null)]
[InlineData("Y10", FocasAreaKind.Pmc, "Y", 10, null)]
[InlineData("F20.3", FocasAreaKind.Pmc, "F", 20, 3)]
[InlineData("G54", FocasAreaKind.Pmc, "G", 54, null)]
[InlineData("R100", FocasAreaKind.Pmc, "R", 100, null)]
[InlineData("D200", FocasAreaKind.Pmc, "D", 200, null)]
[InlineData("C300", FocasAreaKind.Pmc, "C", 300, null)]
[InlineData("K400", FocasAreaKind.Pmc, "K", 400, null)]
[InlineData("A500", FocasAreaKind.Pmc, "A", 500, null)]
[InlineData("E600", FocasAreaKind.Pmc, "E", 600, null)]
[InlineData("T50.4", FocasAreaKind.Pmc, "T", 50, 4)]
public void Address_parses_PMC_forms(string input, FocasAreaKind kind, string letter, int num, int? bit)
{
var a = FocasAddress.TryParse(input);
a.ShouldNotBeNull();
a.Kind.ShouldBe(kind);
a.PmcLetter.ShouldBe(letter);
a.Number.ShouldBe(num);
a.BitIndex.ShouldBe(bit);
}
[Theory]
[InlineData("PARAM:1020", FocasAreaKind.Parameter, 1020, null)]
[InlineData("PARAM:1815/0", FocasAreaKind.Parameter, 1815, 0)]
[InlineData("PARAM:1815/31", FocasAreaKind.Parameter, 1815, 31)]
public void Address_parses_parameter_forms(string input, FocasAreaKind kind, int num, int? bit)
{
var a = FocasAddress.TryParse(input);
a.ShouldNotBeNull();
a.Kind.ShouldBe(kind);
a.PmcLetter.ShouldBeNull();
a.Number.ShouldBe(num);
a.BitIndex.ShouldBe(bit);
}
[Theory]
[InlineData("MACRO:100", FocasAreaKind.Macro, 100)]
[InlineData("MACRO:500", FocasAreaKind.Macro, 500)]
public void Address_parses_macro_forms(string input, FocasAreaKind kind, int num)
{
var a = FocasAddress.TryParse(input);
a.ShouldNotBeNull();
a.Kind.ShouldBe(kind);
a.Number.ShouldBe(num);
a.BitIndex.ShouldBeNull();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("Z0")] // unknown PMC letter
[InlineData("X")] // missing number
[InlineData("X-1")] // negative number
[InlineData("Xabc")] // non-numeric
[InlineData("X0.8")] // bit out of range (0-7)
[InlineData("X0.-1")] // negative bit
[InlineData("PARAM:")] // missing number
[InlineData("PARAM:1815/32")] // bit out of range (0-31)
[InlineData("MACRO:abc")] // non-numeric
public void Address_rejects_invalid_forms(string? input)
{
FocasAddress.TryParse(input).ShouldBeNull();
}
[Theory]
[InlineData("X0.0")]
[InlineData("R100")]
[InlineData("F20.3")]
[InlineData("PARAM:1020")]
[InlineData("PARAM:1815/0")]
[InlineData("MACRO:100")]
public void Address_Canonical_roundtrips(string input)
{
var parsed = FocasAddress.TryParse(input);
parsed.ShouldNotBeNull();
parsed.Canonical.ShouldBe(input);
}
// ---- FocasDataType ----
[Fact]
public void DataType_mapping_covers_atomic_focas_types()
{
FocasDataType.Bit.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
FocasDataType.Int16.ToDriverDataType().ShouldBe(DriverDataType.Int32);
FocasDataType.Int32.ToDriverDataType().ShouldBe(DriverDataType.Int32);
FocasDataType.Float32.ToDriverDataType().ShouldBe(DriverDataType.Float32);
FocasDataType.Float64.ToDriverDataType().ShouldBe(DriverDataType.Float64);
FocasDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
}
// ---- FocasStatusMapper ----
[Theory]
[InlineData(0, FocasStatusMapper.Good)]
[InlineData(3, FocasStatusMapper.BadOutOfRange)] // EW_NUMBER
[InlineData(4, FocasStatusMapper.BadOutOfRange)] // EW_LENGTH
[InlineData(5, FocasStatusMapper.BadNotWritable)] // EW_PROT
[InlineData(6, FocasStatusMapper.BadNotSupported)] // EW_NOOPT
[InlineData(8, FocasStatusMapper.BadNodeIdUnknown)] // EW_DATA
[InlineData(-1, FocasStatusMapper.BadDeviceFailure)] // EW_BUSY
[InlineData(-8, FocasStatusMapper.BadInternalError)] // EW_HANDLE
[InlineData(-16, FocasStatusMapper.BadCommunicationError)] // EW_SOCKET
[InlineData(999, FocasStatusMapper.BadCommunicationError)] // unknown → generic
public void StatusMapper_covers_known_focas_returns(int ret, uint expected)
{
FocasStatusMapper.MapFocasReturn(ret).ShouldBe(expected);
}
// ---- FocasDriver ----
[Fact]
public void DriverType_is_FOCAS()
{
var drv = new FocasDriver(new FocasDriverOptions(), "drv-1");
drv.DriverType.ShouldBe("FOCAS");
drv.DriverInstanceId.ShouldBe("drv-1");
}
[Fact]
public async Task InitializeAsync_parses_device_addresses()
{
var drv = new FocasDriver(new FocasDriverOptions
{
Devices =
[
new FocasDeviceOptions("focas://10.0.0.5:8193"),
new FocasDeviceOptions("focas://10.0.0.6:12345", DeviceName: "CNC-2"),
],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.DeviceCount.ShouldBe(2);
drv.GetDeviceState("focas://10.0.0.5:8193")!.ParsedAddress.Port.ShouldBe(8193);
drv.GetDeviceState("focas://10.0.0.6:12345")!.Options.DeviceName.ShouldBe("CNC-2");
}
[Fact]
public async Task InitializeAsync_malformed_address_faults()
{
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("not-an-address")],
}, "drv-1");
await Should.ThrowAsync<InvalidOperationException>(
() => drv.InitializeAsync("{}", CancellationToken.None));
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
[Fact]
public async Task ShutdownAsync_clears_devices()
{
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
drv.DeviceCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
// ---- UnimplementedFocasClientFactory ----
[Fact]
public void Default_factory_throws_on_Create_with_deployment_pointer()
{
var factory = new UnimplementedFocasClientFactory();
var ex = Should.Throw<NotSupportedException>(() => factory.Create());
ex.Message.ShouldContain("Fwlib32.dll");
ex.Message.ShouldContain("licensed");
}
}

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

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>