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,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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