chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)

Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions
@@ -0,0 +1,57 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
/// <summary>
/// Probes a Fanuc CNC: opens a FOCAS session + reads one PMC address. Uses the managed
/// <c>WireFocasClient</c> on TCP:8193. Against an unreachable endpoint it surfaces
/// <c>BadCommunicationError</c> which is still a useful signal that the CLI wire-up is
/// correct. Also runs cleanly against the focas-mock Docker fixture in
/// <c>tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/</c>.
/// </summary>
[Command("probe", Description = "Verify the CNC is reachable + a sample FOCAS read succeeds.")]
public sealed class ProbeCommand : FocasCommandBase
{
[CommandOption("address", 'a', Description =
"FOCAS address to probe (default R100 — PMC R-file register 100).")]
public string Address { get; init; } = "R100";
[CommandOption("type", Description = "Data type (default Int16).")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
var probeTag = new FocasTagDefinition(
Name: "__probe",
DeviceHostAddress: HostAddress,
Address: Address,
DataType: DataType,
Writable: false);
var options = BuildOptions([probeTag]);
await using var driver = new FocasDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync(["__probe"], ct);
var health = driver.GetHealth();
await console.Output.WriteLineAsync($"CNC: {CncHost}:{CncPort}");
await console.Output.WriteLineAsync($"Series: {Series}");
await console.Output.WriteLineAsync($"Health: {health.State}");
if (health.LastError is { } err)
await console.Output.WriteLineAsync($"Last error: {err}");
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
}
@@ -0,0 +1,52 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
/// <summary>
/// Read one FOCAS address (PMC R/G/F file, parameter, macro, axis register).
/// </summary>
[Command("read", Description = "Read a single FOCAS address.")]
public sealed class ReadCommand : FocasCommandBase
{
[CommandOption("address", 'a', Description =
"FOCAS address. Examples: R100 (PMC R-file word); X0.0 (PMC X-bit); " +
"PARAM:1815/0 (parameter 1815, axis 0); MACRO:500 (macro variable 500).",
IsRequired = true)]
public string Address { get; init; } = default!;
[CommandOption("type", 't', Description =
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
var tagName = SynthesiseTagName(Address, DataType);
var tag = new FocasTagDefinition(
Name: tagName,
DeviceHostAddress: HostAddress,
Address: Address,
DataType: DataType,
Writable: false);
var options = BuildOptions([tag]);
await using var driver = new FocasDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
internal static string SynthesiseTagName(string address, FocasDataType type)
=> $"{address}:{type}";
}
@@ -0,0 +1,76 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
/// <summary>
/// Watch a FOCAS address via polled subscription until Ctrl+C. FOCAS has no push
/// model; <c>PollGroupEngine</c> handles the tick loop.
/// </summary>
[Command("subscribe", Description = "Watch a FOCAS address via polled subscription until Ctrl+C.")]
public sealed class SubscribeCommand : FocasCommandBase
{
[CommandOption("address", 'a', Description = "FOCAS address — same format as `read`.", IsRequired = true)]
public string Address { get; init; } = default!;
[CommandOption("type", 't', Description =
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
[CommandOption("interval-ms", 'i', Description = "Publishing interval ms (default 1000).")]
public int IntervalMs { get; init; } = 1000;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
var tag = new FocasTagDefinition(
Name: tagName,
DeviceHostAddress: HostAddress,
Address: Address,
DataType: DataType,
Writable: false);
var options = BuildOptions([tag]);
await using var driver = new FocasDriver(options, DriverInstanceId);
ISubscriptionHandle? handle = null;
try
{
await driver.InitializeAsync("{}", ct);
driver.OnDataChange += (_, e) =>
{
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
console.Output.WriteLine(line);
};
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
await console.Output.WriteLineAsync(
$"Subscribed to {Address} @ {IntervalMs}ms. Ctrl+C to stop.");
try
{
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
}
catch (OperationCanceledException)
{
// Expected on Ctrl+C.
}
}
finally
{
if (handle is not null)
{
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
catch { /* teardown best-effort */ }
}
await driver.ShutdownAsync(CancellationToken.None);
}
}
}
@@ -0,0 +1,77 @@
using System.Globalization;
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
/// <summary>
/// Write one value to a FOCAS address. PMC G/R writes are real — be careful
/// which file you hit on a running machine. Parameter writes may require the
/// CNC to be in MDI mode + the parameter-write switch enabled.
/// </summary>
[Command("write", Description = "Write a single FOCAS address.")]
public sealed class WriteCommand : FocasCommandBase
{
[CommandOption("address", 'a', Description = "FOCAS address — same format as `read`.", IsRequired = true)]
public string Address { get; init; } = default!;
[CommandOption("type", 't', Description =
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
[CommandOption("value", 'v', Description =
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
IsRequired = true)]
public string Value { get; init; } = default!;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
var tag = new FocasTagDefinition(
Name: tagName,
DeviceHostAddress: HostAddress,
Address: Address,
DataType: DataType,
Writable: true);
var options = BuildOptions([tag]);
var parsed = ParseValue(Value, DataType);
await using var driver = new FocasDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
internal static object ParseValue(string raw, FocasDataType type) => type switch
{
FocasDataType.Bit => ParseBool(raw),
FocasDataType.Byte => sbyte.Parse(raw, CultureInfo.InvariantCulture),
FocasDataType.Int16 => short.Parse(raw, CultureInfo.InvariantCulture),
FocasDataType.Int32 => int.Parse(raw, CultureInfo.InvariantCulture),
FocasDataType.Float32 => float.Parse(raw, CultureInfo.InvariantCulture),
FocasDataType.Float64 => double.Parse(raw, CultureInfo.InvariantCulture),
FocasDataType.String => raw,
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
};
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
{
"1" or "true" or "on" or "yes" => true,
"0" or "false" or "off" or "no" => false,
_ => throw new CliFx.Exceptions.CommandException(
$"Boolean value '{raw}' is not recognised. Use true/false, 1/0, on/off, or yes/no."),
};
}
@@ -0,0 +1,57 @@
using CliFx.Attributes;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli;
/// <summary>
/// Base for every FOCAS CLI command. Carries the CNC endpoint options
/// (host / port / series) + exposes <see cref="BuildOptions"/> so each command
/// can synthesise a <see cref="FocasDriverOptions"/> with one device + one tag.
/// </summary>
public abstract class FocasCommandBase : DriverCommandBase
{
[CommandOption("cnc-host", 'h', Description =
"CNC IP address or hostname. FOCAS-over-EIP listens on port 8193 by default.",
IsRequired = true)]
public string CncHost { get; init; } = default!;
[CommandOption("cnc-port", 'p', Description = "FOCAS TCP port (default 8193).")]
public int CncPort { get; init; } = 8193;
[CommandOption("series", 's', Description =
"CNC series: Unknown / Zero_i_D / Zero_i_F / Zero_i_MF / Zero_i_TF / Sixteen_i / " +
"Thirty_i / ThirtyOne_i / ThirtyTwo_i / PowerMotion_i (default Unknown).")]
public FocasCncSeries Series { get; init; } = FocasCncSeries.Unknown;
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 2000).")]
public int TimeoutMs { get; init; } = 2000;
/// <inheritdoc />
public override TimeSpan Timeout
{
get => TimeSpan.FromMilliseconds(TimeoutMs);
init { /* driven by TimeoutMs */ }
}
/// <summary>Canonical FOCAS host-address string, shape <c>focas://host:port</c>.</summary>
protected string HostAddress => $"focas://{CncHost}:{CncPort}";
/// <summary>
/// Build a <see cref="FocasDriverOptions"/> with the CNC target this base collected
/// + the tag list a subclass supplies. Probe disabled; the driver's default managed
/// wire client opens a TCP:8193 session to the CNC and surfaces unreachable endpoints
/// as <c>BadCommunicationError</c>.
/// </summary>
protected FocasDriverOptions BuildOptions(IReadOnlyList<FocasTagDefinition> tags) => new()
{
Devices = [new FocasDeviceOptions(
HostAddress: HostAddress,
DeviceName: $"cli-{CncHost}:{CncPort}",
Series: Series)],
Tags = tags,
Timeout = Timeout,
Probe = new FocasProbeOptions { Enabled = false },
};
protected string DriverInstanceId => $"focas-cli-{CncHost}:{CncPort}";
}
@@ -0,0 +1,12 @@
using CliFx;
return await new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.SetExecutableName("otopcua-focas-cli")
.SetDescription(
"OtOpcUa FOCAS test-client — ad-hoc probe + PMC/param/macro reads + polled " +
"subscriptions against Fanuc CNCs via the FOCAS/2 protocol. Uses the managed " +
"WireFocasClient on TCP:8193 directly; no native dependencies. Addresses use " +
"FocasAddressParser syntax: R100, X0.0, PARAM:1815/0, MACRO:500.")
.Build()
.RunAsync(args);
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<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.Cli</RootNamespace>
<AssemblyName>otopcua-focas-cli</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="2.3.6"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
<ProjectReference Include="..\..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
</ItemGroup>
<!-- CLI runs the managed WireFocasClient and talks to the CNC over TCP:8193
directly — no Fwlib64.dll copy step needed. -->
</Project>