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

View File

@@ -0,0 +1,51 @@
using CliFx.Attributes;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli;
/// <summary>
/// Base for every AB Legacy CLI command. Carries the PCCC-specific endpoint options
/// (<c>--gateway</c> + <c>--plc-type</c>) on top of <see cref="DriverCommandBase"/>'s
/// shared verbose + timeout + logging helpers.
/// </summary>
public abstract class AbLegacyCommandBase : DriverCommandBase
{
[CommandOption("gateway", 'g', Description =
"Canonical AB Legacy gateway: ab://host[:port]/cip-path. Port defaults to 44818. " +
"cip-path depends on the family: SLC 5/05 + PLC-5 typically '1,0'; MicroLogix " +
"1100/1400 takes an empty path (direct EIP, no backplane).",
IsRequired = true)]
public string Gateway { get; init; } = default!;
[CommandOption("plc-type", 'P', Description =
"Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).")]
public AbLegacyPlcFamily PlcType { get; init; } = AbLegacyPlcFamily.Slc500;
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
/// <inheritdoc />
public override TimeSpan Timeout
{
get => TimeSpan.FromMilliseconds(TimeoutMs);
init { /* driven by TimeoutMs */ }
}
/// <summary>
/// Build an <see cref="AbLegacyDriverOptions"/> with the device + tag list a subclass
/// supplies. Probe disabled for CLI one-shot runs.
/// </summary>
protected AbLegacyDriverOptions BuildOptions(IReadOnlyList<AbLegacyTagDefinition> tags) => new()
{
Devices = [new AbLegacyDeviceOptions(
HostAddress: Gateway,
PlcFamily: PlcType,
DeviceName: $"cli-{PlcType}")],
Tags = tags,
Timeout = Timeout,
Probe = new AbLegacyProbeOptions { Enabled = false },
};
protected string DriverInstanceId => $"ablegacy-cli-{Gateway}";
}

View File

@@ -0,0 +1,57 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
/// <summary>
/// Probes an AB Legacy (PCCC) endpoint: reads one N-file word + reports driver health.
/// Default probe address <c>N7:0</c> matches the integration-fixture seed so operators
/// can point the CLI at the ab_server Docker container + real hardware interchangeably.
/// </summary>
[Command("probe", Description = "Verify the AB Legacy endpoint is reachable and a sample PCCC read succeeds.")]
public sealed class ProbeCommand : AbLegacyCommandBase
{
[CommandOption("address", 'a', Description =
"PCCC address to probe (default N7:0). Use S:0 for the status file when you want " +
"the pre-populated register every SLC / MicroLogix / PLC-5 ships with.")]
public string Address { get; init; } = "N7:0";
[CommandOption("type", Description =
"PCCC data type of the probe address (default Int — matches N files).")]
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
var probeTag = new AbLegacyTagDefinition(
Name: "__probe",
DeviceHostAddress: Gateway,
Address: Address,
DataType: DataType,
Writable: false);
var options = BuildOptions([probeTag]);
await using var driver = new AbLegacyDriver(options, DriverInstanceId);
try
{
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync(["__probe"], ct);
var health = driver.GetHealth();
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
await console.Output.WriteLineAsync($"PLC type: {PlcType}");
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);
}
}
}

View File

@@ -0,0 +1,55 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
/// <summary>
/// Read one PCCC address (N7:0, F8:0, B3:0/3, L19:0, ST17:0, T4:0.ACC, etc.).
/// </summary>
[Command("read", Description = "Read a single PCCC file address.")]
public sealed class ReadCommand : AbLegacyCommandBase
{
[CommandOption("address", 'a', Description =
"PCCC file address. File letter implies storage; bit-within-word via slash " +
"(B3:0/3 or N7:0/5). Sub-element access for timers/counters/controls uses " +
"dot notation (T4:0.ACC, C5:0.PRE, R6:0.LEN).",
IsRequired = true)]
public string Address { get; init; } = default!;
[CommandOption("type", 't', Description =
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
"ControlElement (default Int).")]
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
var ct = console.RegisterCancellationHandler();
var tagName = SynthesiseTagName(Address, DataType);
var tag = new AbLegacyTagDefinition(
Name: tagName,
DeviceHostAddress: Gateway,
Address: Address,
DataType: DataType,
Writable: false);
var options = BuildOptions([tag]);
await using var driver = new AbLegacyDriver(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);
}
}
/// <summary>Tag-name key the driver uses internally. Address+type is already unique.</summary>
internal static string SynthesiseTagName(string address, AbLegacyDataType type)
=> $"{address}:{type}";
}

View File

@@ -0,0 +1,78 @@
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.AbLegacy.Cli.Commands;
/// <summary>
/// Watch a PCCC file address via polled subscription until Ctrl+C. Mirrors the Modbus /
/// AB CIP subscribe shape — PollGroupEngine handles the tick loop.
/// </summary>
[Command("subscribe", Description = "Watch a PCCC file address via polled subscription until Ctrl+C.")]
public sealed class SubscribeCommand : AbLegacyCommandBase
{
[CommandOption("address", 'a', Description = "PCCC file address — same format as `read`.", IsRequired = true)]
public string Address { get; init; } = default!;
[CommandOption("type", 't', Description =
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
"ControlElement (default Int).")]
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
[CommandOption("interval-ms", 'i', Description =
"Publishing interval in milliseconds (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 AbLegacyTagDefinition(
Name: tagName,
DeviceHostAddress: Gateway,
Address: Address,
DataType: DataType,
Writable: false);
var options = BuildOptions([tag]);
await using var driver = new AbLegacyDriver(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);
}
}
}

View File

@@ -0,0 +1,81 @@
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.AbLegacy.Cli.Commands;
/// <summary>
/// Write one value to a PCCC file address. Writes to timer / counter / control
/// sub-elements go through at the wire level but land on the integer field of the
/// sub-element — the PLC's runtime semantics (edge-triggered EN/DN bits, preset reloads)
/// are PLC-managed, not CLI-manipulable; write these with caution.
/// </summary>
[Command("write", Description = "Write a single PCCC file address.")]
public sealed class WriteCommand : AbLegacyCommandBase
{
[CommandOption("address", 'a', Description =
"PCCC file address — same format as `read`.", IsRequired = true)]
public string Address { get; init; } = default!;
[CommandOption("type", 't', Description =
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
"ControlElement (default Int).")]
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
[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 AbLegacyTagDefinition(
Name: tagName,
DeviceHostAddress: Gateway,
Address: Address,
DataType: DataType,
Writable: true);
var options = BuildOptions([tag]);
var parsed = ParseValue(Value, DataType);
await using var driver = new AbLegacyDriver(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);
}
}
/// <summary>Parse <c>--value</c> per <see cref="AbLegacyDataType"/>, invariant culture.</summary>
internal static object ParseValue(string raw, AbLegacyDataType type) => type switch
{
AbLegacyDataType.Bit => ParseBool(raw),
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => short.Parse(raw, CultureInfo.InvariantCulture),
AbLegacyDataType.Long => int.Parse(raw, CultureInfo.InvariantCulture),
AbLegacyDataType.Float => float.Parse(raw, CultureInfo.InvariantCulture),
AbLegacyDataType.String => raw,
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
or AbLegacyDataType.ControlElement => int.Parse(raw, CultureInfo.InvariantCulture),
_ => 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."),
};
}

View File

@@ -0,0 +1,11 @@
using CliFx;
return await new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.SetExecutableName("otopcua-ablegacy-cli")
.SetDescription(
"OtOpcUa AB Legacy test-client — ad-hoc probe + PCCC N/F/B/L-file reads/writes + " +
"polled subscriptions against SLC 500 / MicroLogix / PLC-5 devices via libplctag. " +
"Addresses use PCCC convention: N7:0, F8:0, B3:0/3, L19:0, ST17:0.")
.Build()
.RunAsync(args);

View File

@@ -0,0 +1,29 @@
<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.AbLegacy.Cli</RootNamespace>
<AssemblyName>otopcua-ablegacy-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.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests"/>
</ItemGroup>
</Project>