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
+
+
+
+
+
+
+
+
+
+
+
+
+