4e8df38bb2
Closes #254
131 lines
5.6 KiB
C#
131 lines
5.6 KiB
C#
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}.";
|
|
}
|