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:
Joseph Doherty
2026-04-25 18:33:55 -04:00
parent 7ee0cbc3f4
commit 08d8a104bb
6 changed files with 689 additions and 0 deletions

View 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("\"", "\"\"") + "\"";
}
}

View 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;
}
}