diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 90401dd..fb6c76e 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -13,6 +13,7 @@ + @@ -35,6 +36,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs new file mode 100644 index 0000000..1dba425 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs @@ -0,0 +1,95 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// +/// Parsed FOCAS address covering the three addressing spaces a driver touches: +/// (letter + byte + optional bit — X0.0, R100, +/// F20.3), (CNC parameter number — +/// PARAM:1020, PARAM:1815/0 for bit 0), and +/// (macro variable number — MACRO:100, MACRO:500). +/// +/// +/// PMC letters: X/Y (IO), F/G (signals between PMC + CNC), R (internal +/// relay), D (data table), C (counter), K (keep relay), A +/// (message display), E (extended relay), T (timer). Byte numbering is 0-based; +/// bit index when present is 0–7 and uses .N for PMC or /N for parameters. +/// +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, + }; +} + +/// Addressing-space kinds the driver understands. +public enum FocasAreaKind +{ + Pmc, + Parameter, + Macro, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDataType.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDataType.cs new file mode 100644 index 0000000..def934b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDataType.cs @@ -0,0 +1,39 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// +/// 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. +/// +public enum FocasDataType +{ + /// Single bit (PMC bit, or bit within a CNC parameter). + Bit, + /// 8-bit signed byte (PMC 1-byte read). + Byte, + /// 16-bit signed word (PMC 2-byte read, or CNC parameter as short). + Int16, + /// 32-bit signed int (PMC 4-byte read, or CNC parameter as int). + Int32, + /// 32-bit IEEE-754 float (rare; some CNC macro variables). + Float32, + /// 64-bit IEEE-754 double (most macro variables are double-precision). + Float64, + /// ASCII string (alarm text, parameter names, some PMC string areas). + 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, + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs new file mode 100644 index 0000000..3abbb56 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -0,0 +1,89 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// +/// 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 +/// the deployment supplies — FWLIB itself is Fanuc-proprietary +/// and cannot be redistributed. +/// +/// +/// PR 1 ships only; read / write / discover / subscribe / probe / host- +/// resolver capabilities land in PRs 2 and 3. The abstraction +/// shipped here lets PR 2 onward stay license-clean — all tests run against a fake client +/// + the default makes misconfigured servers +/// fail fast. +/// +public sealed class FocasDriver : IDriver, IDisposable, IAsyncDisposable +{ + private readonly FocasDriverOptions _options; + private readonly string _driverInstanceId; + private readonly IFocasClientFactory _clientFactory; + private readonly Dictionary _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; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs new file mode 100644 index 0000000..579c53c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs @@ -0,0 +1,38 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// +/// FOCAS driver configuration. One instance supports N CNC devices. Per plan decision #144 +/// each device gets its own (DriverInstanceId, HostAddress) bulkhead key at the +/// Phase 6.1 resilience layer. +/// +public sealed class FocasDriverOptions +{ + public IReadOnlyList Devices { get; init; } = []; + public IReadOnlyList 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); + +/// +/// One FOCAS-backed OPC UA variable. is the canonical FOCAS +/// address string that parses via — +/// X0.0 / R100 / PARAM:1815/0 / MACRO:500. +/// +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); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasHostAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasHostAddress.cs new file mode 100644 index 0000000..c4a18bd --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasHostAddress.cs @@ -0,0 +1,41 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// +/// Parsed FOCAS target address — IP + TCP port. Canonical focas://{ip}[:{port}]. +/// Default port 8193 (Fanuc-reserved FOCAS Ethernet port). +/// +public sealed record FocasHostAddress(string Host, int Port) +{ + /// Fanuc-reserved TCP port for FOCAS Ethernet. + 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); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs new file mode 100644 index 0000000..565e900 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs @@ -0,0 +1,48 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// +/// Maps FOCAS / FWLIB return codes to OPC UA StatusCodes. The FWLIB C API uses an +/// EW_* constant family per the Fanuc FOCAS/1 and FOCAS/2 documentation +/// (EW_OK = 0, EW_NUMBER, EW_SOCKET, etc.). Mirrors the shape of the +/// AbCip / TwinCAT mappers so Admin UI status displays stay uniform across drivers. +/// +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; + + /// + /// Map common FWLIB EW_* 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). + /// + 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, + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs new file mode 100644 index 0000000..4c7733a --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -0,0 +1,70 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +/// +/// Wire-layer abstraction over one FOCAS session to a CNC. The driver holds one per +/// configured device; lifetime matches the device. +/// +/// +/// No default wire implementation ships with this assembly. FWLIB +/// (Fwlib32.dll) is Fanuc-proprietary and requires a valid customer license — it +/// cannot legally be redistributed. The deployment team supplies an +/// that wraps the licensed Fwlib32.dll via +/// P/Invoke and registers it at server startup. +/// +/// The default throws with a pointer at +/// the deployment docs so misconfigured servers fail fast with a clear error rather than +/// mysteriously hanging. +/// +public interface IFocasClient : IDisposable +{ + /// Open the FWLIB handle + TCP session. Idempotent. + Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken); + + /// True when the FWLIB handle is valid + the socket is up. + bool IsConnected { get; } + + /// + /// Read the value at in the requested + /// . Returns a boxed .NET value + the OPC UA status mapped + /// through . + /// + Task<(object? value, uint status)> ReadAsync( + FocasAddress address, + FocasDataType type, + CancellationToken cancellationToken); + + /// + /// Write to . Returns the mapped + /// OPC UA status (0 = Good). + /// + Task WriteAsync( + FocasAddress address, + FocasDataType type, + object? value, + CancellationToken cancellationToken); + + /// + /// Cheap health probe — e.g. cnc_rdcncstat. Returns true when the CNC + /// responds with any valid status. + /// + Task ProbeAsync(CancellationToken cancellationToken); +} + +/// Factory for s. One client per configured device. +public interface IFocasClientFactory +{ + IFocasClient Create(); +} + +/// +/// 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. +/// +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."); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj new file mode 100644 index 0000000..15b82fe --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.FOCAS + ZB.MOM.WW.OtOpcUa.Driver.FOCAS + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasScaffoldingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasScaffoldingTests.cs new file mode 100644 index 0000000..a8f6c94 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasScaffoldingTests.cs @@ -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( + () => 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(() => factory.Create()); + ex.Message.ShouldContain("Fwlib32.dll"); + ex.Message.ShouldContain("licensed"); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj new file mode 100644 index 0000000..5262aa7 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + +