diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/TagExportCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/TagExportCommand.cs
new file mode 100644
index 0000000..38ad8b1
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/TagExportCommand.cs
@@ -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;
+
+///
+/// Dump the merged tag table from an JSON config to a
+/// Kepware-format CSV. The command reads the pre-declared Tags list, pulls in any
+/// L5kImports / L5xImports / CsvImports 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 CsvImports.
+///
+///
+/// The command does not contact any PLC — it is a pure transform over the options JSON.
+/// --driver-options-json may point at a full options file or at a fragment that
+/// deserialises to .
+///
+[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(json, JsonOpts)
+ ?? throw new CommandException("driver-options-json deserialised to null.");
+
+ var basePath = Path.GetDirectoryName(Path.GetFullPath(DriverOptionsJsonPath)) ?? string.Empty;
+
+ var declaredNames = new HashSet(
+ opts.Tags.Select(t => t.Name), StringComparer.OrdinalIgnoreCase);
+ var allTags = new List(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 parse,
+ HashSet declaredNames, List 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 declaredNames, List 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() },
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index 6287fe0..d89fb51 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -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,
}
}
+ ///
+ /// CSV-import variant of . The CSV path produces
+ /// 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.
+ ///
+ private static void MergeCsvImport(
+ AbCipCsvImportOptions import,
+ HashSet declaredNames,
+ List 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);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
index deedda2..2a6f4c7 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
@@ -42,6 +42,16 @@ public sealed class AbCipDriverOptions
///
public IReadOnlyList L5xImports { get; init; } = [];
+ ///
+ /// Kepware-format CSV imports merged into at InitializeAsync.
+ /// Same merge semantics as / —
+ /// pre-declared entries win on Name 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
+ /// for the column layout + parse rules.
+ ///
+ public IReadOnlyList CsvImports { get; init; } = [];
+
/// Per-device probe settings. Falls back to defaults when omitted.
public AbCipProbeOptions Probe { get; init; } = new();
@@ -187,6 +197,20 @@ public sealed record AbCipL5xImportOptions(
string? InlineText = null,
string NamePrefix = "");
+///
+/// One Kepware-format CSV import entry. Field shape mirrors
+/// so configuration JSON stays consistent across the three import sources.
+///
+/// Target device HostAddress tags from this file are bound to.
+/// On-disk path to a Kepware-format *.csv. Loaded eagerly at InitializeAsync.
+/// Pre-loaded CSV body — used by tests + Admin UI uploads.
+/// Optional prefix prepended to imported tag names to avoid collisions.
+public sealed record AbCipCsvImportOptions(
+ string DeviceHostAddress,
+ string? FilePath = null,
+ string? InlineText = null,
+ string NamePrefix = "");
+
/// Which AB PLC family the device is — selects the profile applied to connection params.
public enum AbCipPlcFamily
{
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagExporter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagExporter.cs
new file mode 100644
index 0000000..f345c7d
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagExporter.cs
@@ -0,0 +1,99 @@
+using System.Globalization;
+using System.IO;
+using System.Text;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
+
+///
+/// Render an enumerable of as a Kepware-format CSV
+/// document. Emits the header expected by so the importer
+/// and exporter form a complete round-trip path: load → export → reparse → identical
+/// entries (modulo unknown-type tags, which export as STRING and reimport as
+/// per the importer's fall-through rule).
+///
+public static class CsvTagExporter
+{
+ public static readonly IReadOnlyList KepwareColumns =
+ [
+ "Tag Name",
+ "Address",
+ "Data Type",
+ "Respect Data Type",
+ "Client Access",
+ "Scan Rate",
+ "Description",
+ "Scaling",
+ ];
+
+ /// Write the tag list to in Kepware CSV format.
+ public static void Write(IEnumerable 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)));
+ }
+ }
+
+ /// Render the tag list to a string.
+ public static string ToCsv(IEnumerable tags)
+ {
+ using var sw = new StringWriter(CultureInfo.InvariantCulture);
+ Write(tags, sw);
+ return sw.ToString();
+ }
+
+ /// Write the tag list to as UTF-8 (no BOM).
+ public static void WriteFile(IEnumerable 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",
+ };
+
+ /// Quote a field if it contains comma, quote, CR, or LF; escape embedded quotes by doubling.
+ 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("\"", "\"\"") + "\"";
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagImporter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagImporter.cs
new file mode 100644
index 0000000..cee686a
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagImporter.cs
@@ -0,0 +1,226 @@
+using System.Globalization;
+using System.IO;
+using System.Text;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
+
+///
+/// Parse a Kepware-format AB CIP tag CSV into entries.
+/// The expected column layout matches the Kepware EX tag-export shape so operators can
+/// round-trip tags through Excel without re-keying:
+/// Tag Name, Address, Data Type, Respect Data Type, Client Access, Scan Rate,
+/// Description, Scaling. 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
+/// (; / #) are skipped.
+///
+///
+///
+/// Mapping: Tag Name → ;
+/// Address → ;
+/// Data Type → (Logix atomic name —
+/// BOOL/SINT/INT/DINT/REAL/STRING/...; unknown values fall through as
+/// the same way handles
+/// unknown types);
+/// Description → ;
+/// Client Access → : any value
+/// containing W (case-insensitive) is treated as Read/Write; everything else
+/// is Read-Only.
+///
+///
+/// CSV semantics are RFC-4180-ish: double-quoted fields support embedded commas, line
+/// breaks, and escaped quotes (""). The parser is single-pass + deliberately
+/// narrow — Kepware's exporter does not produce anything more exotic.
+///
+///
+public sealed class CsvTagImporter
+{
+ /// Default device host address applied to every imported tag.
+ public string DefaultDeviceHostAddress { get; init; } = string.Empty;
+
+ /// Optional prefix prepended to each imported tag's name. Default empty.
+ 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();
+ var skippedBlank = 0;
+ Dictionary? 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 BuildHeader(IReadOnlyList row)
+ {
+ var dict = new Dictionary(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 row, Dictionary 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,
+ };
+}
+
+/// Result of .
+public sealed record CsvTagImportResult(
+ IReadOnlyList Tags,
+ int SkippedBlankCount);
+
+///
+/// Tiny RFC-4180-ish CSV reader. Supports double-quoted fields, escaped ""
+/// 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.
+///
+internal static class CsvReader
+{
+ public static List> ReadAll(string text)
+ {
+ var rows = new List>();
+ var row = new List();
+ 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();
+ 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();
+ break;
+ default:
+ field.Append(c);
+ break;
+ }
+ }
+
+ if (field.Length > 0 || row.Count > 0)
+ {
+ row.Add(field.ToString());
+ rows.Add(row);
+ }
+
+ return rows;
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CsvTagImporterTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CsvTagImporterTests.cs
new file mode 100644
index 0000000..10ec26d
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CsvTagImporterTests.cs
@@ -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(() => 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();
+ }
+}