Auto: ablegacy-11 — RSLogix 500/PLC-5 CSV symbol import

Closes #254
This commit is contained in:
Joseph Doherty
2026-04-26 04:13:13 -04:00
parent 4fdeef7a6c
commit 4e8df38bb2
19 changed files with 1644 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
using System.IO;
using System.Text.Json;
using CliFx;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
/// <summary>
/// ablegacy-11 / #254 — read an RSLogix 500 / 5 "Database Export" CSV and emit either an
/// <c>appsettings.json</c> tag fragment or a summary line. Avoids the AbLegacyCommandBase
/// hierarchy because import is a purely-offline operation: no gateway, no driver, no
/// timeout. Mirrors the
/// <a href="https://github.com/dohertj2/lmxopcua/issues/254">#254 plan section</a>'s CLI
/// specification verbatim.
/// </summary>
[Command("import-rslogix", Description =
"Read an RSLogix 500/5 CSV symbol export and emit a JSON tag fragment for appsettings.json. " +
"Binary .RSS / .RSP project files are out of scope (see docs/drivers/AbLegacy-RSLogix-Import.md).")]
public sealed class ImportRslogixCommand : ICommand
{
[CommandOption("file", 'f', Description =
"Path to the RSLogix CSV export. RFC 4180-ish format with header columns " +
"Symbol,Address,Description,DataType,Scope; quoted fields + doubled-quote escapes " +
"are honoured; comment lines starting with ; or # are skipped.",
IsRequired = true)]
public string File { get; init; } = default!;
[CommandOption("device", 'd', Description =
"Canonical AB Legacy gateway URI (ab://host[:port]/cip-path) every imported tag " +
"binds to. Required even though import is offline — the resulting tag definitions " +
"carry the gateway address as their DeviceHostAddress.",
IsRequired = true)]
public string Device { get; init; } = default!;
[CommandOption("emit", Description =
"Output shape: 'appsettings-fragment' (default) emits a JSON object with a Tags array " +
"ready to paste into appsettings.json; 'summary' emits one human-readable counter line.")]
public string Emit { get; init; } = "appsettings-fragment";
[CommandOption("output", 'o', Description =
"Optional output file. When omitted, the result goes to stdout.")]
public string? Output { get; init; }
[CommandOption("scope", Description =
"Optional Scope filter — match the row's Scope column against this value " +
"case-insensitively. Common values: 'Global', 'Local:1', 'Local:2'. Rows with " +
"no Scope column count as Global.")]
public string? Scope { get; init; }
[CommandOption("max-rows", Description =
"Defensive cap on the number of rows imported. Beyond the cap the parser stops " +
"and emits a warning; useful for dry-running a large export against the CLI.")]
public int? MaxRows { get; init; }
[CommandOption("strict", Description =
"When set, the first malformed row throws and the CLI exits non-zero. Default is " +
"permissive (skip + log).")]
public bool Strict { get; init; }
public async ValueTask ExecuteAsync(IConsole console)
{
if (!System.IO.File.Exists(File))
{
// Surface a clean exit-code-1 with a one-line error rather than letting
// FileNotFoundException bubble up through CliFx's default exception path —
// the CLI tests and operators both prefer `import-rslogix --file missing.csv`
// to print "file not found" rather than a stack trace.
throw new CommandException($"RSLogix CSV not found: {File}", exitCode: 1);
}
var opts = new ImportOptions(
ScopeFilter: Scope,
MaxRowsToImport: MaxRows,
IgnoreInvalid: !Strict);
RsLogixImportResult result;
using (var stream = System.IO.File.OpenRead(File))
{
var importer = new RsLogixSymbolImport();
result = importer.Parse(stream, Device, opts);
}
var emit = Emit?.Trim().ToLowerInvariant();
var payload = emit switch
{
"summary" => FormatSummary(result),
"appsettings-fragment" or null or "" => FormatFragment(result),
_ => throw new CommandException(
$"Unknown --emit value '{Emit}'. Use 'appsettings-fragment' or 'summary'.",
exitCode: 2),
};
if (Output is { Length: > 0 })
{
await System.IO.File.WriteAllTextAsync(Output, payload);
await console.Output.WriteLineAsync(
$"Wrote {result.ParsedCount} tag(s) to {Output} (skipped={result.SkippedCount}, errors={result.ErrorCount}).");
}
else
{
await console.Output.WriteLineAsync(payload);
}
}
/// <summary>
/// Serialise the imported tag list as a JSON fragment shaped like the
/// <c>AbLegacyDriverConfigDto</c>'s <c>Tags</c> array — drop straight into the
/// <c>appsettings.json</c> driver config under
/// <c>Drivers/&lt;instance&gt;/Config/Tags</c>.
/// </summary>
internal static string FormatFragment(RsLogixImportResult result)
{
var tags = result.Tags.Select(t => new
{
Name = t.Name,
DeviceHostAddress = t.DeviceHostAddress,
Address = t.Address,
DataType = t.DataType.ToString(),
Writable = t.Writable,
}).ToArray();
var doc = new { Tags = tags };
return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
}
private static string FormatSummary(RsLogixImportResult result) =>
$"Imported {result.ParsedCount} tag(s), skipped {result.SkippedCount}, errors {result.ErrorCount}.";
}