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:
@@ -152,6 +152,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
declaredNames: declaredNames,
|
||||
allTags: allTags);
|
||||
}
|
||||
foreach (var import in _options.CsvImports)
|
||||
{
|
||||
MergeCsvImport(import, declaredNames, 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)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -42,6 +42,16 @@ public sealed class AbCipDriverOptions
|
||||
/// </summary>
|
||||
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>
|
||||
public AbCipProbeOptions Probe { get; init; } = new();
|
||||
|
||||
@@ -187,6 +197,20 @@ public sealed record AbCipL5xImportOptions(
|
||||
string? InlineText = null,
|
||||
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>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user