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() },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -152,6 +152,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
declaredNames: declaredNames,
|
declaredNames: declaredNames,
|
||||||
allTags: allTags);
|
allTags: allTags);
|
||||||
}
|
}
|
||||||
|
foreach (var import in _options.CsvImports)
|
||||||
|
{
|
||||||
|
MergeCsvImport(import, declaredNames, allTags);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var tag in allTags)
|
foreach (var tag in allTags)
|
||||||
{
|
{
|
||||||
@@ -234,6 +238,43 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CSV-import variant of <see cref="MergeImport"/>. The CSV path produces
|
||||||
|
/// <see cref="AbCipTagDefinition"/> records directly (no intermediate document) so we
|
||||||
|
/// can't share the L5K/L5X parser-delegate signature. Merge semantics are identical:
|
||||||
|
/// a name already covered by a declaration or an earlier import is left untouched so
|
||||||
|
/// the precedence chain (declared > L5K > L5X > CSV) holds.
|
||||||
|
/// </summary>
|
||||||
|
private static void MergeCsvImport(
|
||||||
|
AbCipCsvImportOptions import,
|
||||||
|
HashSet<string> declaredNames,
|
||||||
|
List<AbCipTagDefinition> allTags)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(import.DeviceHostAddress))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"AbCip CSV import is missing DeviceHostAddress — every imported tag needs a target device.");
|
||||||
|
|
||||||
|
string? csvText = null;
|
||||||
|
if (!string.IsNullOrEmpty(import.FilePath))
|
||||||
|
csvText = System.IO.File.ReadAllText(import.FilePath);
|
||||||
|
else if (!string.IsNullOrEmpty(import.InlineText))
|
||||||
|
csvText = import.InlineText;
|
||||||
|
if (csvText is null) return;
|
||||||
|
|
||||||
|
var importer = new CsvTagImporter
|
||||||
|
{
|
||||||
|
DefaultDeviceHostAddress = import.DeviceHostAddress,
|
||||||
|
NamePrefix = import.NamePrefix,
|
||||||
|
};
|
||||||
|
var result = importer.Import(csvText);
|
||||||
|
foreach (var tag in result.Tags)
|
||||||
|
{
|
||||||
|
if (declaredNames.Contains(tag.Name)) continue;
|
||||||
|
allTags.Add(tag);
|
||||||
|
declaredNames.Add(tag.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ public sealed class AbCipDriverOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<AbCipL5xImportOptions> L5xImports { get; init; } = [];
|
public IReadOnlyList<AbCipL5xImportOptions> L5xImports { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Kepware-format CSV imports merged into <see cref="Tags"/> at <c>InitializeAsync</c>.
|
||||||
|
/// Same merge semantics as <see cref="L5kImports"/> / <see cref="L5xImports"/> —
|
||||||
|
/// pre-declared <see cref="Tags"/> entries win on <c>Name</c> conflicts, and tags
|
||||||
|
/// produced by earlier import collections (L5K → L5X → CSV in call order) also win
|
||||||
|
/// so an Excel-edited copy of the same controller does not double-emit. See
|
||||||
|
/// <see cref="Import.CsvTagImporter"/> for the column layout + parse rules.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<AbCipCsvImportOptions> CsvImports { get; init; } = [];
|
||||||
|
|
||||||
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
|
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
|
||||||
public AbCipProbeOptions Probe { get; init; } = new();
|
public AbCipProbeOptions Probe { get; init; } = new();
|
||||||
|
|
||||||
@@ -187,6 +197,20 @@ public sealed record AbCipL5xImportOptions(
|
|||||||
string? InlineText = null,
|
string? InlineText = null,
|
||||||
string NamePrefix = "");
|
string NamePrefix = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One Kepware-format CSV import entry. Field shape mirrors <see cref="AbCipL5kImportOptions"/>
|
||||||
|
/// so configuration JSON stays consistent across the three import sources.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
|
||||||
|
/// <param name="FilePath">On-disk path to a Kepware-format <c>*.csv</c>. Loaded eagerly at InitializeAsync.</param>
|
||||||
|
/// <param name="InlineText">Pre-loaded CSV body — used by tests + Admin UI uploads.</param>
|
||||||
|
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions.</param>
|
||||||
|
public sealed record AbCipCsvImportOptions(
|
||||||
|
string DeviceHostAddress,
|
||||||
|
string? FilePath = null,
|
||||||
|
string? InlineText = null,
|
||||||
|
string NamePrefix = "");
|
||||||
|
|
||||||
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
|
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
|
||||||
public enum AbCipPlcFamily
|
public enum AbCipPlcFamily
|
||||||
{
|
{
|
||||||
|
|||||||
99
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagExporter.cs
Normal file
99
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagExporter.cs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Render an enumerable of <see cref="AbCipTagDefinition"/> as a Kepware-format CSV
|
||||||
|
/// document. Emits the header expected by <see cref="CsvTagImporter"/> so the importer
|
||||||
|
/// and exporter form a complete round-trip path: load → export → reparse → identical
|
||||||
|
/// entries (modulo unknown-type tags, which export as <c>STRING</c> and reimport as
|
||||||
|
/// <see cref="AbCipDataType.Structure"/> per the importer's fall-through rule).
|
||||||
|
/// </summary>
|
||||||
|
public static class CsvTagExporter
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyList<string> KepwareColumns =
|
||||||
|
[
|
||||||
|
"Tag Name",
|
||||||
|
"Address",
|
||||||
|
"Data Type",
|
||||||
|
"Respect Data Type",
|
||||||
|
"Client Access",
|
||||||
|
"Scan Rate",
|
||||||
|
"Description",
|
||||||
|
"Scaling",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Write the tag list to <paramref name="writer"/> in Kepware CSV format.</summary>
|
||||||
|
public static void Write(IEnumerable<AbCipTagDefinition> tags, TextWriter writer)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(tags);
|
||||||
|
ArgumentNullException.ThrowIfNull(writer);
|
||||||
|
|
||||||
|
writer.WriteLine(string.Join(",", KepwareColumns.Select(EscapeField)));
|
||||||
|
foreach (var tag in tags)
|
||||||
|
{
|
||||||
|
var fields = new[]
|
||||||
|
{
|
||||||
|
tag.Name ?? string.Empty,
|
||||||
|
tag.TagPath ?? string.Empty,
|
||||||
|
FormatDataType(tag.DataType),
|
||||||
|
"1", // Respect Data Type — Kepware EX default.
|
||||||
|
tag.Writable ? "Read/Write" : "Read Only",
|
||||||
|
"100", // Scan Rate (ms) — placeholder default.
|
||||||
|
tag.Description ?? string.Empty,
|
||||||
|
"None", // Scaling — driver doesn't apply scaling.
|
||||||
|
};
|
||||||
|
writer.WriteLine(string.Join(",", fields.Select(EscapeField)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Render the tag list to a string.</summary>
|
||||||
|
public static string ToCsv(IEnumerable<AbCipTagDefinition> tags)
|
||||||
|
{
|
||||||
|
using var sw = new StringWriter(CultureInfo.InvariantCulture);
|
||||||
|
Write(tags, sw);
|
||||||
|
return sw.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Write the tag list to <paramref name="path"/> as UTF-8 (no BOM).</summary>
|
||||||
|
public static void WriteFile(IEnumerable<AbCipTagDefinition> tags, string path)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(path);
|
||||||
|
using var sw = new StreamWriter(path, append: false, new UTF8Encoding(false));
|
||||||
|
Write(tags, sw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatDataType(AbCipDataType t) => t switch
|
||||||
|
{
|
||||||
|
AbCipDataType.Bool => "BOOL",
|
||||||
|
AbCipDataType.SInt => "SINT",
|
||||||
|
AbCipDataType.Int => "INT",
|
||||||
|
AbCipDataType.DInt => "DINT",
|
||||||
|
AbCipDataType.LInt => "LINT",
|
||||||
|
AbCipDataType.USInt => "USINT",
|
||||||
|
AbCipDataType.UInt => "UINT",
|
||||||
|
AbCipDataType.UDInt => "UDINT",
|
||||||
|
AbCipDataType.ULInt => "ULINT",
|
||||||
|
AbCipDataType.Real => "REAL",
|
||||||
|
AbCipDataType.LReal => "LREAL",
|
||||||
|
AbCipDataType.String => "STRING",
|
||||||
|
AbCipDataType.Dt => "DT",
|
||||||
|
AbCipDataType.Structure => "STRING", // Surface UDT-typed tags as STRING — Kepware has no UDT cell.
|
||||||
|
_ => "STRING",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Quote a field if it contains comma, quote, CR, or LF; escape embedded quotes by doubling.</summary>
|
||||||
|
private static string EscapeField(string value)
|
||||||
|
{
|
||||||
|
value ??= string.Empty;
|
||||||
|
var needsQuotes =
|
||||||
|
value.IndexOf(',') >= 0 ||
|
||||||
|
value.IndexOf('"') >= 0 ||
|
||||||
|
value.IndexOf('\r') >= 0 ||
|
||||||
|
value.IndexOf('\n') >= 0;
|
||||||
|
if (!needsQuotes) return value;
|
||||||
|
return "\"" + value.Replace("\"", "\"\"") + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagImporter.cs
Normal file
226
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagImporter.cs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a Kepware-format AB CIP tag CSV into <see cref="AbCipTagDefinition"/> entries.
|
||||||
|
/// The expected column layout matches the Kepware EX tag-export shape so operators can
|
||||||
|
/// round-trip tags through Excel without re-keying:
|
||||||
|
/// <c>Tag Name, Address, Data Type, Respect Data Type, Client Access, Scan Rate,
|
||||||
|
/// Description, Scaling</c>. The first non-blank, non-comment row is treated as the
|
||||||
|
/// header — column order is honoured by name lookup, so reorderings out of Excel still
|
||||||
|
/// work. Blank rows + rows whose first cell starts with a Kepware section marker
|
||||||
|
/// (<c>;</c> / <c>#</c>) are skipped.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Mapping: <c>Tag Name</c> → <see cref="AbCipTagDefinition.Name"/>;
|
||||||
|
/// <c>Address</c> → <see cref="AbCipTagDefinition.TagPath"/>;
|
||||||
|
/// <c>Data Type</c> → <see cref="AbCipTagDefinition.DataType"/> (Logix atomic name —
|
||||||
|
/// BOOL/SINT/INT/DINT/REAL/STRING/...; unknown values fall through as
|
||||||
|
/// <see cref="AbCipDataType.Structure"/> the same way <see cref="L5kIngest"/> handles
|
||||||
|
/// unknown types);
|
||||||
|
/// <c>Description</c> → <see cref="AbCipTagDefinition.Description"/>;
|
||||||
|
/// <c>Client Access</c> → <see cref="AbCipTagDefinition.Writable"/>: any value
|
||||||
|
/// containing <c>W</c> (case-insensitive) is treated as Read/Write; everything else
|
||||||
|
/// is Read-Only.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// CSV semantics are RFC-4180-ish: double-quoted fields support embedded commas, line
|
||||||
|
/// breaks, and escaped quotes (<c>""</c>). The parser is single-pass + deliberately
|
||||||
|
/// narrow — Kepware's exporter does not produce anything more exotic.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CsvTagImporter
|
||||||
|
{
|
||||||
|
/// <summary>Default device host address applied to every imported tag.</summary>
|
||||||
|
public string DefaultDeviceHostAddress { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Optional prefix prepended to each imported tag's name. Default empty.</summary>
|
||||||
|
public string NamePrefix { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public CsvTagImportResult Import(string csvText)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(csvText);
|
||||||
|
if (string.IsNullOrWhiteSpace(DefaultDeviceHostAddress))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"{nameof(CsvTagImporter)}.{nameof(DefaultDeviceHostAddress)} must be set before {nameof(Import)} is called — every imported tag needs a target device.");
|
||||||
|
|
||||||
|
var rows = CsvReader.ReadAll(csvText);
|
||||||
|
var tags = new List<AbCipTagDefinition>();
|
||||||
|
var skippedBlank = 0;
|
||||||
|
Dictionary<string, int>? header = null;
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
if (row.Count == 0 || row.All(string.IsNullOrWhiteSpace))
|
||||||
|
{
|
||||||
|
skippedBlank++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var first = row[0].TrimStart();
|
||||||
|
if (first.StartsWith(';') || first.StartsWith('#'))
|
||||||
|
{
|
||||||
|
skippedBlank++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (header is null)
|
||||||
|
{
|
||||||
|
header = BuildHeader(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = GetCell(row, header, "Tag Name");
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
skippedBlank++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var address = GetCell(row, header, "Address");
|
||||||
|
var dataTypeText = GetCell(row, header, "Data Type");
|
||||||
|
var description = GetCell(row, header, "Description");
|
||||||
|
var clientAccess = GetCell(row, header, "Client Access");
|
||||||
|
|
||||||
|
var dataType = ParseDataType(dataTypeText);
|
||||||
|
var writable = !string.IsNullOrEmpty(clientAccess)
|
||||||
|
&& clientAccess.IndexOf('W', StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
|
||||||
|
tags.Add(new AbCipTagDefinition(
|
||||||
|
Name: string.IsNullOrEmpty(NamePrefix) ? name : $"{NamePrefix}{name}",
|
||||||
|
DeviceHostAddress: DefaultDeviceHostAddress,
|
||||||
|
TagPath: string.IsNullOrEmpty(address) ? name : address,
|
||||||
|
DataType: dataType,
|
||||||
|
Writable: writable,
|
||||||
|
Description: string.IsNullOrEmpty(description) ? null : description));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CsvTagImportResult(tags, skippedBlank);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CsvTagImportResult ImportFile(string path) =>
|
||||||
|
Import(File.ReadAllText(path, Encoding.UTF8));
|
||||||
|
|
||||||
|
private static Dictionary<string, int> BuildHeader(IReadOnlyList<string> row)
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
for (var i = 0; i < row.Count; i++)
|
||||||
|
{
|
||||||
|
var key = row[i]?.Trim() ?? string.Empty;
|
||||||
|
if (key.Length > 0 && !dict.ContainsKey(key))
|
||||||
|
dict[key] = i;
|
||||||
|
}
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCell(IReadOnlyList<string> row, Dictionary<string, int> header, string column)
|
||||||
|
{
|
||||||
|
if (!header.TryGetValue(column, out var idx)) return string.Empty;
|
||||||
|
if (idx < 0 || idx >= row.Count) return string.Empty;
|
||||||
|
return row[idx]?.Trim() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AbCipDataType ParseDataType(string s) =>
|
||||||
|
s?.Trim().ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"BOOL" or "BIT" => AbCipDataType.Bool,
|
||||||
|
"SINT" or "BYTE" => AbCipDataType.SInt,
|
||||||
|
"INT" or "WORD" or "SHORT" => AbCipDataType.Int,
|
||||||
|
"DINT" or "DWORD" or "LONG" => AbCipDataType.DInt,
|
||||||
|
"LINT" => AbCipDataType.LInt,
|
||||||
|
"USINT" => AbCipDataType.USInt,
|
||||||
|
"UINT" => AbCipDataType.UInt,
|
||||||
|
"UDINT" => AbCipDataType.UDInt,
|
||||||
|
"ULINT" => AbCipDataType.ULInt,
|
||||||
|
"REAL" or "FLOAT" => AbCipDataType.Real,
|
||||||
|
"LREAL" or "DOUBLE" => AbCipDataType.LReal,
|
||||||
|
"STRING" => AbCipDataType.String,
|
||||||
|
"DT" or "DATETIME" or "DATE" => AbCipDataType.Dt,
|
||||||
|
_ => AbCipDataType.Structure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Result of <see cref="CsvTagImporter.Import"/>.</summary>
|
||||||
|
public sealed record CsvTagImportResult(
|
||||||
|
IReadOnlyList<AbCipTagDefinition> Tags,
|
||||||
|
int SkippedBlankCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tiny RFC-4180-ish CSV reader. Supports double-quoted fields, escaped <c>""</c>
|
||||||
|
/// quotes, and embedded line breaks inside quotes. Internal because the importer +
|
||||||
|
/// exporter are the only two callers and we don't want to add a CSV dep.
|
||||||
|
/// </summary>
|
||||||
|
internal static class CsvReader
|
||||||
|
{
|
||||||
|
public static List<List<string>> ReadAll(string text)
|
||||||
|
{
|
||||||
|
var rows = new List<List<string>>();
|
||||||
|
var row = new List<string>();
|
||||||
|
var field = new StringBuilder();
|
||||||
|
var inQuotes = false;
|
||||||
|
|
||||||
|
for (var i = 0; i < text.Length; i++)
|
||||||
|
{
|
||||||
|
var c = text[i];
|
||||||
|
if (inQuotes)
|
||||||
|
{
|
||||||
|
if (c == '"')
|
||||||
|
{
|
||||||
|
if (i + 1 < text.Length && text[i + 1] == '"')
|
||||||
|
{
|
||||||
|
field.Append('"');
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
field.Append(c);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case '"':
|
||||||
|
inQuotes = true;
|
||||||
|
break;
|
||||||
|
case ',':
|
||||||
|
row.Add(field.ToString());
|
||||||
|
field.Clear();
|
||||||
|
break;
|
||||||
|
case '\r':
|
||||||
|
// Swallow CR — handle CRLF and lone CR alike.
|
||||||
|
row.Add(field.ToString());
|
||||||
|
field.Clear();
|
||||||
|
rows.Add(row);
|
||||||
|
row = new List<string>();
|
||||||
|
if (i + 1 < text.Length && text[i + 1] == '\n') i++;
|
||||||
|
break;
|
||||||
|
case '\n':
|
||||||
|
row.Add(field.ToString());
|
||||||
|
field.Clear();
|
||||||
|
rows.Add(row);
|
||||||
|
row = new List<string>();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
field.Append(c);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.Length > 0 || row.Count > 0)
|
||||||
|
{
|
||||||
|
row.Add(field.ToString());
|
||||||
|
rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class CsvTagImporterTests
|
||||||
|
{
|
||||||
|
private const string DeviceHost = "ab://10.10.10.1/0,1";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Imports_Kepware_format_controller_tag_with_RW_access()
|
||||||
|
{
|
||||||
|
const string csv = """
|
||||||
|
Tag Name,Address,Data Type,Respect Data Type,Client Access,Scan Rate,Description,Scaling
|
||||||
|
Motor1_Speed,Motor1_Speed,DINT,1,Read/Write,100,Drive speed setpoint,None
|
||||||
|
""";
|
||||||
|
|
||||||
|
var importer = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost };
|
||||||
|
var result = importer.Import(csv);
|
||||||
|
|
||||||
|
result.Tags.Count.ShouldBe(1);
|
||||||
|
var t = result.Tags[0];
|
||||||
|
t.Name.ShouldBe("Motor1_Speed");
|
||||||
|
t.TagPath.ShouldBe("Motor1_Speed");
|
||||||
|
t.DataType.ShouldBe(AbCipDataType.DInt);
|
||||||
|
t.Writable.ShouldBeTrue();
|
||||||
|
t.Description.ShouldBe("Drive speed setpoint");
|
||||||
|
t.DeviceHostAddress.ShouldBe(DeviceHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Read_Only_access_yields_non_writable_tag()
|
||||||
|
{
|
||||||
|
const string csv = """
|
||||||
|
Tag Name,Address,Data Type,Client Access,Description
|
||||||
|
Sensor,Sensor,REAL,Read Only,Pressure sensor
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||||
|
|
||||||
|
result.Tags.Single().Writable.ShouldBeFalse();
|
||||||
|
result.Tags.Single().DataType.ShouldBe(AbCipDataType.Real);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Blank_rows_and_section_markers_are_skipped()
|
||||||
|
{
|
||||||
|
const string csv = """
|
||||||
|
; Kepware Server Tag Export
|
||||||
|
|
||||||
|
Tag Name,Address,Data Type,Client Access
|
||||||
|
|
||||||
|
; group: Motors
|
||||||
|
Motor1,Motor1,DINT,Read/Write
|
||||||
|
|
||||||
|
Motor2,Motor2,DINT,Read/Write
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||||
|
|
||||||
|
result.Tags.Count.ShouldBe(2);
|
||||||
|
result.Tags.Select(t => t.Name).ShouldBe(["Motor1", "Motor2"]);
|
||||||
|
result.SkippedBlankCount.ShouldBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Quoted_field_with_embedded_comma_is_parsed()
|
||||||
|
{
|
||||||
|
const string csv = """
|
||||||
|
Tag Name,Address,Data Type,Client Access,Description
|
||||||
|
Motor1,Motor1,DINT,Read/Write,"Speed, RPM"
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||||
|
|
||||||
|
result.Tags.Single().Description.ShouldBe("Speed, RPM");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Quoted_field_with_escaped_quote_is_parsed()
|
||||||
|
{
|
||||||
|
const string csv = "Tag Name,Address,Data Type,Client Access,Description\r\n"
|
||||||
|
+ "Tag1,Tag1,DINT,Read Only,\"He said \"\"hi\"\"\"\r\n";
|
||||||
|
|
||||||
|
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||||
|
|
||||||
|
result.Tags.Single().Description.ShouldBe("He said \"hi\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NamePrefix_is_applied()
|
||||||
|
{
|
||||||
|
const string csv = """
|
||||||
|
Tag Name,Address,Data Type,Client Access
|
||||||
|
Speed,Speed,DINT,Read/Write
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new CsvTagImporter
|
||||||
|
{
|
||||||
|
DefaultDeviceHostAddress = DeviceHost,
|
||||||
|
NamePrefix = "PLC1_",
|
||||||
|
}.Import(csv);
|
||||||
|
|
||||||
|
result.Tags.Single().Name.ShouldBe("PLC1_Speed");
|
||||||
|
result.Tags.Single().TagPath.ShouldBe("Speed");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_data_type_falls_through_as_Structure()
|
||||||
|
{
|
||||||
|
const string csv = """
|
||||||
|
Tag Name,Address,Data Type,Client Access
|
||||||
|
Mystery,Mystery,SomeUnknownType,Read/Write
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||||
|
|
||||||
|
result.Tags.Single().DataType.ShouldBe(AbCipDataType.Structure);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Throws_when_DefaultDeviceHostAddress_missing()
|
||||||
|
{
|
||||||
|
const string csv = "Tag Name,Address,Data Type,Client Access\nA,A,DINT,Read/Write\n";
|
||||||
|
|
||||||
|
Should.Throw<InvalidOperationException>(() => new CsvTagImporter().Import(csv));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Round_trip_load_export_reparse_is_stable()
|
||||||
|
{
|
||||||
|
var original = new[]
|
||||||
|
{
|
||||||
|
new AbCipTagDefinition("Motor1", DeviceHost, "Motor1", AbCipDataType.DInt,
|
||||||
|
Writable: true, Description: "Drive speed"),
|
||||||
|
new AbCipTagDefinition("Sensor", DeviceHost, "Sensor", AbCipDataType.Real,
|
||||||
|
Writable: false, Description: "Pressure, kPa"),
|
||||||
|
new AbCipTagDefinition("Tag3", DeviceHost, "Program:Main.Tag3", AbCipDataType.Bool,
|
||||||
|
Writable: true, Description: null),
|
||||||
|
};
|
||||||
|
|
||||||
|
var csv = CsvTagExporter.ToCsv(original);
|
||||||
|
var reparsed = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv).Tags;
|
||||||
|
|
||||||
|
reparsed.Count.ShouldBe(original.Length);
|
||||||
|
for (var i = 0; i < original.Length; i++)
|
||||||
|
{
|
||||||
|
reparsed[i].Name.ShouldBe(original[i].Name);
|
||||||
|
reparsed[i].TagPath.ShouldBe(original[i].TagPath);
|
||||||
|
reparsed[i].DataType.ShouldBe(original[i].DataType);
|
||||||
|
reparsed[i].Writable.ShouldBe(original[i].Writable);
|
||||||
|
reparsed[i].Description.ShouldBe(original[i].Description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reordered_columns_are_honoured_via_header_lookup()
|
||||||
|
{
|
||||||
|
const string csv = """
|
||||||
|
Description,Address,Tag Name,Client Access,Data Type
|
||||||
|
Drive speed,Motor1,Motor1,Read/Write,DINT
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||||
|
|
||||||
|
var t = result.Tags.Single();
|
||||||
|
t.Name.ShouldBe("Motor1");
|
||||||
|
t.TagPath.ShouldBe("Motor1");
|
||||||
|
t.DataType.ShouldBe(AbCipDataType.DInt);
|
||||||
|
t.Description.ShouldBe("Drive speed");
|
||||||
|
t.Writable.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user