Auto: abcip-2.4 — CSV tag import/export
CsvTagImporter / CsvTagExporter parse and emit Kepware-format AB CIP tag CSVs (Tag Name, Address, Data Type, Respect Data Type, Client Access, Scan Rate, Description, Scaling). Import maps Tag Name → AbCipTagDefinition.Name, Address → TagPath, Data Type → DataType, Description → Description, Client Access → Writable. Skips blank rows + ;/# section markers; honours column reordering via header lookup; RFC-4180-ish quoting. CsvImports collection on AbCipDriverOptions mirrors L5kImports/L5xImports and is consumed by InitializeAsync (declared > L5K > L5X > CSV precedence). CLI tag-export command dumps the merged tag table from a driver-options JSON to a Kepware CSV — runs the same import-merge precedence the driver uses but without contacting any PLC. Tests cover R/W mapping, blank-row skip, quoted comma, escaped quote, name prefix, unknown-type fall-through, header reordering, and a load → export → reparse round-trip. Closes #232
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Dump the merged tag table from an <see cref="AbCipDriverOptions"/> JSON config to a
|
||||
/// Kepware-format CSV. The command reads the pre-declared <c>Tags</c> list, pulls in any
|
||||
/// <c>L5kImports</c> / <c>L5xImports</c> / <c>CsvImports</c> entries, applies the same
|
||||
/// declared-wins precedence used by the live driver, and writes the union as one CSV.
|
||||
/// Mirrors the round-trip path operators want for Excel-driven editing: export → edit →
|
||||
/// re-import via the driver's <c>CsvImports</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The command does not contact any PLC — it is a pure transform over the options JSON.
|
||||
/// <c>--driver-options-json</c> may point at a full options file or at a fragment that
|
||||
/// deserialises to <see cref="AbCipDriverOptions"/>.
|
||||
/// </remarks>
|
||||
[Command("tag-export", Description = "Export the merged tag table from a driver-options JSON to Kepware CSV.")]
|
||||
public sealed class TagExportCommand : ICommand
|
||||
{
|
||||
[CommandOption("driver-options-json", Description =
|
||||
"Path to a JSON file deserialising to AbCipDriverOptions (Tags + L5kImports + " +
|
||||
"L5xImports + CsvImports). Imports with FilePath are loaded relative to the JSON.",
|
||||
IsRequired = true)]
|
||||
public string DriverOptionsJsonPath { get; init; } = default!;
|
||||
|
||||
[CommandOption("out", 'o', Description = "Output CSV path (UTF-8, no BOM).", IsRequired = true)]
|
||||
public string OutputPath { get; init; } = default!;
|
||||
|
||||
public ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
if (!File.Exists(DriverOptionsJsonPath))
|
||||
throw new CommandException($"driver-options-json '{DriverOptionsJsonPath}' does not exist.");
|
||||
|
||||
var json = File.ReadAllText(DriverOptionsJsonPath);
|
||||
var opts = JsonSerializer.Deserialize<AbCipDriverOptions>(json, JsonOpts)
|
||||
?? throw new CommandException("driver-options-json deserialised to null.");
|
||||
|
||||
var basePath = Path.GetDirectoryName(Path.GetFullPath(DriverOptionsJsonPath)) ?? string.Empty;
|
||||
|
||||
var declaredNames = new HashSet<string>(
|
||||
opts.Tags.Select(t => t.Name), StringComparer.OrdinalIgnoreCase);
|
||||
var allTags = new List<AbCipTagDefinition>(opts.Tags);
|
||||
|
||||
foreach (var import in opts.L5kImports)
|
||||
MergeL5(import.DeviceHostAddress, ResolvePath(import.FilePath, basePath),
|
||||
import.InlineText, import.NamePrefix, L5kParser.Parse, declaredNames, allTags);
|
||||
foreach (var import in opts.L5xImports)
|
||||
MergeL5(import.DeviceHostAddress, ResolvePath(import.FilePath, basePath),
|
||||
import.InlineText, import.NamePrefix, L5xParser.Parse, declaredNames, allTags);
|
||||
foreach (var import in opts.CsvImports)
|
||||
MergeCsv(import, basePath, declaredNames, allTags);
|
||||
|
||||
CsvTagExporter.WriteFile(allTags, OutputPath);
|
||||
console.Output.WriteLine($"Wrote {allTags.Count} tag(s) to {OutputPath}");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static string? ResolvePath(string? path, string basePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return path;
|
||||
return Path.IsPathRooted(path) ? path : Path.Combine(basePath, path);
|
||||
}
|
||||
|
||||
private static void MergeL5(
|
||||
string deviceHost, string? filePath, string? inlineText, string namePrefix,
|
||||
Func<IL5kSource, L5kDocument> parse,
|
||||
HashSet<string> declaredNames, List<AbCipTagDefinition> allTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceHost)) return;
|
||||
IL5kSource? src = null;
|
||||
if (!string.IsNullOrEmpty(filePath)) src = new FileL5kSource(filePath);
|
||||
else if (!string.IsNullOrEmpty(inlineText)) src = new StringL5kSource(inlineText);
|
||||
if (src is null) return;
|
||||
|
||||
var doc = parse(src);
|
||||
var ingest = new L5kIngest { DefaultDeviceHostAddress = deviceHost, NamePrefix = namePrefix };
|
||||
foreach (var tag in ingest.Ingest(doc).Tags)
|
||||
{
|
||||
if (declaredNames.Contains(tag.Name)) continue;
|
||||
allTags.Add(tag);
|
||||
declaredNames.Add(tag.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private static void MergeCsv(
|
||||
AbCipCsvImportOptions import, string basePath,
|
||||
HashSet<string> declaredNames, List<AbCipTagDefinition> allTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(import.DeviceHostAddress)) return;
|
||||
string? text = null;
|
||||
var resolved = ResolvePath(import.FilePath, basePath);
|
||||
if (!string.IsNullOrEmpty(resolved)) text = File.ReadAllText(resolved);
|
||||
else if (!string.IsNullOrEmpty(import.InlineText)) text = import.InlineText;
|
||||
if (text is null) return;
|
||||
|
||||
var importer = new CsvTagImporter
|
||||
{
|
||||
DefaultDeviceHostAddress = import.DeviceHostAddress,
|
||||
NamePrefix = import.NamePrefix,
|
||||
};
|
||||
foreach (var tag in importer.Import(text).Tags)
|
||||
{
|
||||
if (declaredNames.Contains(tag.Name)) continue;
|
||||
allTags.Add(tag);
|
||||
declaredNames.Add(tag.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user