Compare commits
2 Commits
twincat-na
...
focas-pr1-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
285799a954 | ||
| 9da578d5a5 |
@@ -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"/>
|
||||
|
||||
95
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs
Normal file
95
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs
Normal 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 0–7 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,
|
||||
}
|
||||
39
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDataType.cs
Normal file
39
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDataType.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
89
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
Normal file
89
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
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, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly FocasDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IFocasClientFactory _clientFactory;
|
||||
private readonly Dictionary<string, DeviceState> _devices = 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 UnimplementedFocasClientFactory();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
_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)
|
||||
{
|
||||
_devices.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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
38
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs
Normal file
38
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs
Normal 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);
|
||||
}
|
||||
41
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasHostAddress.cs
Normal file
41
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasHostAddress.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
48
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs
Normal file
48
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
70
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
Normal file
70
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
Normal 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.");
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user