@@ -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/<instance>/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}.";
|
||||
}
|
||||
Reference in New Issue
Block a user