Auto: ablegacy-11 — RSLogix 500/PLC-5 CSV symbol import

Closes #254
This commit is contained in:
Joseph Doherty
2026-04-26 04:13:13 -04:00
parent 4fdeef7a6c
commit 4e8df38bb2
19 changed files with 1644 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
using System.IO;
using System.Text.Json;
using CliFx;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
/// <summary>
/// ablegacy-11 / #254 — read an RSLogix 500 / 5 "Database Export" CSV and emit either an
/// <c>appsettings.json</c> tag fragment or a summary line. Avoids the AbLegacyCommandBase
/// hierarchy because import is a purely-offline operation: no gateway, no driver, no
/// timeout. Mirrors the
/// <a href="https://github.com/dohertj2/lmxopcua/issues/254">#254 plan section</a>'s CLI
/// specification verbatim.
/// </summary>
[Command("import-rslogix", Description =
"Read an RSLogix 500/5 CSV symbol export and emit a JSON tag fragment for appsettings.json. " +
"Binary .RSS / .RSP project files are out of scope (see docs/drivers/AbLegacy-RSLogix-Import.md).")]
public sealed class ImportRslogixCommand : ICommand
{
[CommandOption("file", 'f', Description =
"Path to the RSLogix CSV export. RFC 4180-ish format with header columns " +
"Symbol,Address,Description,DataType,Scope; quoted fields + doubled-quote escapes " +
"are honoured; comment lines starting with ; or # are skipped.",
IsRequired = true)]
public string File { get; init; } = default!;
[CommandOption("device", 'd', Description =
"Canonical AB Legacy gateway URI (ab://host[:port]/cip-path) every imported tag " +
"binds to. Required even though import is offline — the resulting tag definitions " +
"carry the gateway address as their DeviceHostAddress.",
IsRequired = true)]
public string Device { get; init; } = default!;
[CommandOption("emit", Description =
"Output shape: 'appsettings-fragment' (default) emits a JSON object with a Tags array " +
"ready to paste into appsettings.json; 'summary' emits one human-readable counter line.")]
public string Emit { get; init; } = "appsettings-fragment";
[CommandOption("output", 'o', Description =
"Optional output file. When omitted, the result goes to stdout.")]
public string? Output { get; init; }
[CommandOption("scope", Description =
"Optional Scope filter — match the row's Scope column against this value " +
"case-insensitively. Common values: 'Global', 'Local:1', 'Local:2'. Rows with " +
"no Scope column count as Global.")]
public string? Scope { get; init; }
[CommandOption("max-rows", Description =
"Defensive cap on the number of rows imported. Beyond the cap the parser stops " +
"and emits a warning; useful for dry-running a large export against the CLI.")]
public int? MaxRows { get; init; }
[CommandOption("strict", Description =
"When set, the first malformed row throws and the CLI exits non-zero. Default is " +
"permissive (skip + log).")]
public bool Strict { get; init; }
public async ValueTask ExecuteAsync(IConsole console)
{
if (!System.IO.File.Exists(File))
{
// Surface a clean exit-code-1 with a one-line error rather than letting
// FileNotFoundException bubble up through CliFx's default exception path —
// the CLI tests and operators both prefer `import-rslogix --file missing.csv`
// to print "file not found" rather than a stack trace.
throw new CommandException($"RSLogix CSV not found: {File}", exitCode: 1);
}
var opts = new ImportOptions(
ScopeFilter: Scope,
MaxRowsToImport: MaxRows,
IgnoreInvalid: !Strict);
RsLogixImportResult result;
using (var stream = System.IO.File.OpenRead(File))
{
var importer = new RsLogixSymbolImport();
result = importer.Parse(stream, Device, opts);
}
var emit = Emit?.Trim().ToLowerInvariant();
var payload = emit switch
{
"summary" => FormatSummary(result),
"appsettings-fragment" or null or "" => FormatFragment(result),
_ => throw new CommandException(
$"Unknown --emit value '{Emit}'. Use 'appsettings-fragment' or 'summary'.",
exitCode: 2),
};
if (Output is { Length: > 0 })
{
await System.IO.File.WriteAllTextAsync(Output, payload);
await console.Output.WriteLineAsync(
$"Wrote {result.ParsedCount} tag(s) to {Output} (skipped={result.SkippedCount}, errors={result.ErrorCount}).");
}
else
{
await console.Output.WriteLineAsync(payload);
}
}
/// <summary>
/// Serialise the imported tag list as a JSON fragment shaped like the
/// <c>AbLegacyDriverConfigDto</c>'s <c>Tags</c> array — drop straight into the
/// <c>appsettings.json</c> driver config under
/// <c>Drivers/&lt;instance&gt;/Config/Tags</c>.
/// </summary>
internal static string FormatFragment(RsLogixImportResult result)
{
var tags = result.Tags.Select(t => new
{
Name = t.Name,
DeviceHostAddress = t.DeviceHostAddress,
Address = t.Address,
DataType = t.DataType.ToString(),
Writable = t.Writable,
}).ToArray();
var doc = new { Tags = tags };
return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
}
private static string FormatSummary(RsLogixImportResult result) =>
$"Imported {result.ParsedCount} tag(s), skipped {result.SkippedCount}, errors {result.ErrorCount}.";
}

View File

@@ -1,6 +1,10 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
@@ -75,6 +79,80 @@ public static class AbLegacyDriverFactoryExtensions
return new AbLegacyDriver(options, driverInstanceId);
}
/// <summary>
/// ablegacy-11 / #254 — append RSLogix CSV symbol-export rows to
/// <paramref name="options"/> as <see cref="AbLegacyTagDefinition"/> entries bound to
/// <paramref name="deviceHostAddress"/>. Returns a new <see cref="AbLegacyDriverOptions"/>
/// with the imported tags concatenated onto the existing <c>Tags</c> list — useful both
/// at startup-time (server-side bootstrap that wants to seed a device's address space
/// from a customer-supplied CSV) and from the CLI (<c>import-rslogix</c> emits the
/// resulting JSON fragment for hand-merging into an appsettings file).
/// </summary>
/// <remarks>
/// <para>
/// The importer is permissive by default — malformed rows are logged and skipped;
/// the resulting <see cref="RsLogixImportResult"/> counts surface on
/// <paramref name="result"/> for callers that want to assert "we got the row count
/// we expected".
/// </para>
/// <para>
/// RSLogix 500's <c>.RSS</c> + RSLogix 5's <c>.RSP</c> binary project files are
/// out of scope for v1 — the binary format is proprietary and undocumented; no
/// libplctag or community parser exists. Customers must export to text/CSV via
/// RSLogix's "Tools → Database → Save" or "Database Export" before pointing the
/// importer at the file. See <c>docs/drivers/AbLegacy-RSLogix-Import.md</c>.
/// </para>
/// </remarks>
public static AbLegacyDriverOptions AddRsLogixImport(
this AbLegacyDriverOptions options,
string path,
string deviceHostAddress,
out RsLogixImportResult result,
ImportOptions? importOptions = null,
ILogger<RsLogixSymbolImport>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(path);
ArgumentException.ThrowIfNullOrWhiteSpace(deviceHostAddress);
using var stream = File.OpenRead(path);
var importer = new RsLogixSymbolImport(logger ?? NullLogger<RsLogixSymbolImport>.Instance);
result = importer.Parse(stream, deviceHostAddress, importOptions);
// Concat onto whatever's already on the options — the importer is additive so
// hand-edited Tags rows (e.g., system-status fields not surfaced by RSLogix) keep
// sitting alongside the bulk-imported symbol rows. Use init-syntax with-expression
// so the returned options keeps every other field (Devices, Probe, Timeout, …)
// untouched.
var merged = new List<AbLegacyTagDefinition>(options.Tags.Count + result.Tags.Count);
merged.AddRange(options.Tags);
merged.AddRange(result.Tags);
return new AbLegacyDriverOptions
{
Devices = options.Devices,
Tags = merged,
Probe = options.Probe,
Timeout = options.Timeout,
Retries = options.Retries,
};
}
/// <summary>
/// CLI-friendly overload that returns the <see cref="RsLogixImportResult"/> alongside
/// the modified options as a tuple. Mirrors <see cref="AddRsLogixImport"/> but avoids
/// the <c>out</c> parameter for call sites that prefer pattern-matched destructuring.
/// </summary>
public static (AbLegacyDriverOptions Options, RsLogixImportResult Result) AddRsLogixImportWithResult(
this AbLegacyDriverOptions options,
string path,
string deviceHostAddress,
ImportOptions? importOptions = null,
ILogger<RsLogixSymbolImport>? logger = null)
{
var updated = options.AddRsLogixImport(path, deviceHostAddress, out var result, importOptions, logger);
return (updated, result);
}
private static T ParseEnum<T>(string? raw, string driverInstanceId, string field,
string? tagName = null, T? fallback = null) where T : struct, Enum
{

View File

@@ -0,0 +1,40 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
/// <summary>
/// Materialises <see cref="AbLegacyTagDefinition"/> entries from a RSLogix export. v1 ships
/// a single implementation (<see cref="RsLogixSymbolImport"/>) for text/CSV "Database
/// Export" — RSLogix 500's <c>.RSS</c> and RSLogix 5's <c>.RSP</c> binary project files are
/// proprietary and out of scope (no parser ships with libplctag or any community library at
/// the time of writing). The interface exists so a binary parser can slot in later without
/// reshaping the call sites.
/// </summary>
/// <remarks>
/// <para>
/// The <c>deviceHostAddress</c> parameter on <see cref="Parse"/> is required because RSLogix
/// exports list addresses scoped to a single PLC; the importer needs to stamp every
/// resulting tag with the gateway address that the runtime layer will use to reach
/// it. Multi-device deployments call the importer once per device, then concatenate.
/// </para>
/// <para>
/// <see cref="Parse"/> never throws on parse errors when
/// <see cref="ImportOptions.IgnoreInvalid"/> is <c>true</c> (default) — malformed rows
/// are skipped with a structured warning logged via the importer's <c>ILogger</c>, and
/// the counts surface on <see cref="RsLogixImportResult"/>. With
/// <see cref="ImportOptions.IgnoreInvalid"/> set to <c>false</c> the first malformed row
/// throws <see cref="System.IO.InvalidDataException"/>.
/// </para>
/// </remarks>
public interface IRsLogixImporter
{
/// <summary>
/// Read the entire <paramref name="stream"/> and emit one
/// <see cref="AbLegacyTagDefinition"/> per recognised symbol row.
/// </summary>
/// <param name="stream">Open, readable stream over the RSLogix export. Caller owns it.</param>
/// <param name="deviceHostAddress">
/// Canonical AB Legacy gateway URI (<c>ab://host[:port]/cip-path</c>) the resulting
/// tags should bind to.
/// </param>
/// <param name="options">Filter + safety knobs; <c>null</c> ≡ default options.</param>
RsLogixImportResult Parse(Stream stream, string deviceHostAddress, ImportOptions? options = null);
}

View File

@@ -0,0 +1,27 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
/// <summary>
/// Options that drive an <see cref="IRsLogixImporter"/> run. Captures the few knobs that
/// reasonably differ between projects without forcing a dedicated subclass per import shape:
/// scope filter (Global vs. Local:N), maximum rows to keep (defensive cap on suspicious
/// exports), and whether to silently drop malformed rows or surface a parse exception.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="ScopeFilter"/> matches the optional <c>Scope</c> column on RSLogix CSV
/// exports — "Global" tags live at the project root; "Local:1" / "Local:2" / etc. are
/// scoped to ladder file 1, ladder file 2, etc. When non-null, only rows whose
/// <c>Scope</c> value matches case-insensitively are emitted; rows with no <c>Scope</c>
/// column are treated as Global.
/// </para>
/// <para>
/// <see cref="IgnoreInvalid"/> defaults to <c>true</c> — RSLogix exports tend to carry
/// the occasional cosmetic row (single-letter alias, comment-only rows, blank lines)
/// and the v1 contract is "import what we can, log a warning for everything else".
/// Set to <c>false</c> to fail-fast on the first malformed row (useful for CI lint).
/// </para>
/// </remarks>
public sealed record ImportOptions(
string? ScopeFilter = null,
int? MaxRowsToImport = null,
bool IgnoreInvalid = true);

View File

@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
/// <summary>
/// Outcome of a single <see cref="IRsLogixImporter"/> run. <see cref="Tags"/> carries the
/// imported tag definitions ready to drop into <c>AbLegacyDriverOptions.Tags</c>;
/// <see cref="ParsedCount"/>, <see cref="SkippedCount"/>, and <see cref="ErrorCount"/>
/// give the operator a single line of telemetry ("imported 142 / skipped 3 / errored 0")
/// suitable for either a CLI summary or a startup-time log line.
/// </summary>
public sealed record RsLogixImportResult(
IReadOnlyList<AbLegacyTagDefinition> Tags,
int ParsedCount,
int SkippedCount,
int ErrorCount);

View File

@@ -0,0 +1,325 @@
using System.Globalization;
using System.IO;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
/// <summary>
/// Materialises <see cref="AbLegacyTagDefinition"/> entries from RSLogix 500 / 5
/// "Database Export" CSV. The expected column shape is
/// <c>Symbol,Address,Description,DataType,Scope</c> — a slight superset of what RSLogix
/// itself emits ("DataType" is RSLogix-supplied for symbol exports but ignored here in
/// favour of the file-letter prefix on <c>Address</c>; it is left in the schema for
/// forward-compatibility with editor tools that prefer to drive the type explicitly).
/// </summary>
/// <remarks>
/// <para>
/// The parser is deliberately tolerant: header row + comment lines (starting with
/// <c>;</c> or <c>#</c>) are skipped silently, headers are matched case-insensitively,
/// and quoted fields handle embedded commas the way RFC 4180 prescribes ("foo,bar"
/// → <c>foo,bar</c>; doubled quotes inside a quoted field collapse to a single
/// literal quote).
/// </para>
/// <para>
/// Type resolution defers to <see cref="AbLegacyAddress.TryParse(string?)"/> +
/// <see cref="TryResolveDataType"/> so the whole "what kind of file is N7?" knowledge
/// lives in one place. Function-file (<c>RTC</c>, <c>HSC</c>, …) and structure-file
/// (<c>PD</c>, <c>MG</c>, <c>PLS</c>, <c>BT</c>) prefixes are accepted but parsed
/// conditionally on <see cref="PlcFamilies.AbLegacyPlcFamily"/>; for the import path
/// we don't yet know the family so we use Slc500 as the parser context — that family
/// covers every common letter <see cref="RsLogixSymbolImport"/> needs to classify.
/// </para>
/// <para>
/// <see cref="System.IO.InvalidDataException"/> surfaces only when
/// <see cref="ImportOptions.IgnoreInvalid"/> is <c>false</c> — the default permissive
/// path logs a warning per malformed row and bumps the <c>SkippedCount</c> /
/// <c>ErrorCount</c> totals on <see cref="RsLogixImportResult"/>.
/// </para>
/// </remarks>
public sealed class RsLogixSymbolImport : IRsLogixImporter
{
private readonly ILogger<RsLogixSymbolImport> _logger;
public RsLogixSymbolImport() : this(NullLogger<RsLogixSymbolImport>.Instance) { }
public RsLogixSymbolImport(ILogger<RsLogixSymbolImport> logger)
{
_logger = logger ?? NullLogger<RsLogixSymbolImport>.Instance;
}
/// <inheritdoc />
public RsLogixImportResult Parse(Stream stream, string deviceHostAddress, ImportOptions? options = null)
{
ArgumentNullException.ThrowIfNull(stream);
ArgumentException.ThrowIfNullOrWhiteSpace(deviceHostAddress);
var opts = options ?? new ImportOptions();
var tags = new List<AbLegacyTagDefinition>();
var parsed = 0;
var skipped = 0;
var errors = 0;
// detectEncodingFromByteOrderMarks=true honours UTF-8 BOMs (RSLogix tools on Windows
// emit them often) without making the caller reach for a pre-decoded TextReader.
// leaveOpen=true lets the caller manage the stream's lifecycle.
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
int? symbolIdx = null;
int? addressIdx = null;
int? descriptionIdx = null;
int? dataTypeIdx = null;
int? scopeIdx = null;
var headerSeen = false;
var lineNumber = 0;
string? line;
while ((line = reader.ReadLine()) is not null)
{
lineNumber++;
if (string.IsNullOrWhiteSpace(line)) continue;
var trimmed = line.TrimStart();
if (trimmed.StartsWith(';') || trimmed.StartsWith('#')) continue;
var fields = SplitCsv(line);
if (fields.Count == 0) continue;
if (!headerSeen)
{
// First non-blank, non-comment row — treat as header. Map every column we
// recognise; missing required columns short-circuit the whole run with a
// single InvalidDataException because the failure is structural, not
// per-row.
for (var i = 0; i < fields.Count; i++)
{
var header = fields[i].Trim().ToLowerInvariant();
switch (header)
{
case "symbol": symbolIdx = i; break;
case "address": addressIdx = i; break;
case "description": descriptionIdx = i; break;
case "datatype":
case "data type":
case "type": dataTypeIdx = i; break;
case "scope": scopeIdx = i; break;
}
}
if (symbolIdx is null || addressIdx is null)
{
throw new InvalidDataException(
$"RSLogix import header at line {lineNumber} is missing required Symbol or Address column. " +
$"Got: {string.Join(",", fields)}");
}
headerSeen = true;
continue;
}
if (opts.MaxRowsToImport is int cap && parsed >= cap)
{
_logger.LogWarning(
"RSLogix import hit MaxRowsToImport={Cap} at line {LineNumber}; remaining rows skipped.",
cap, lineNumber);
break;
}
// Per-row error scoping — we want a single bad row to skip cleanly without
// dropping the rest of the file. The else branch in IgnoreInvalid=false mode
// re-throws to surface the failure to the caller.
try
{
// symbolIdx + addressIdx are guaranteed non-null past the header gate above.
var symbol = SafeField(fields, symbolIdx!.Value);
var address = SafeField(fields, addressIdx!.Value);
var description = descriptionIdx.HasValue ? SafeField(fields, descriptionIdx.Value) : null;
var scope = scopeIdx.HasValue ? SafeField(fields, scopeIdx.Value) : null;
if (string.IsNullOrWhiteSpace(symbol) || string.IsNullOrWhiteSpace(address))
{
skipped++;
_logger.LogWarning(
"RSLogix CSV row at line {LineNumber} skipped — missing Symbol or Address (symbol='{Symbol}', address='{Address}').",
lineNumber, symbol, address);
continue;
}
// Scope filter: row's Scope (or "Global" when blank) must match the filter
// case-insensitively. RSLogix CSV scope values look like "Global" or
// "Local:N" / "LOCAL:1" depending on the tool that emitted them.
if (opts.ScopeFilter is { } wanted)
{
var actual = string.IsNullOrWhiteSpace(scope) ? "Global" : scope.Trim();
if (!string.Equals(actual, wanted.Trim(), StringComparison.OrdinalIgnoreCase))
{
skipped++;
continue;
}
}
if (!TryResolveDataType(address.Trim(), out var dataType))
{
if (!opts.IgnoreInvalid)
{
throw new InvalidDataException(
$"RSLogix CSV row at line {lineNumber} has unrecognised PCCC address '{address}'.");
}
errors++;
_logger.LogWarning(
"RSLogix CSV row at line {LineNumber} skipped — unrecognised PCCC address '{Address}'.",
lineNumber, address);
continue;
}
// Description column is parsed but currently unused — AbLegacyTagDefinition
// doesn't carry a Description field today (the v2 schema ledger lives on the
// server's metadata side of the bridge per #248). We retain the column in the
// CSV header contract so a future schema bump can pick it up without breaking
// existing exports. _ discard suppresses the unused-local warning.
_ = description;
tags.Add(new AbLegacyTagDefinition(
Name: symbol.Trim(),
DeviceHostAddress: deviceHostAddress,
Address: address.Trim(),
DataType: dataType,
Writable: true));
parsed++;
}
catch (InvalidDataException) when (opts.IgnoreInvalid)
{
errors++;
_logger.LogWarning("RSLogix CSV row at line {LineNumber} skipped — invalid data.", lineNumber);
}
catch (Exception ex) when (opts.IgnoreInvalid)
{
errors++;
_logger.LogWarning(ex, "RSLogix CSV row at line {LineNumber} skipped — parser threw.", lineNumber);
}
}
if (!headerSeen)
{
// Empty CSV (only blanks / comments) — return an empty result rather than
// surface a "no header found" error. The CLI will report parsed=0 which is the
// honest answer.
return new RsLogixImportResult([], 0, skipped, errors);
}
return new RsLogixImportResult(tags, parsed, skipped, errors);
}
/// <summary>
/// Resolve a PCCC <paramref name="address"/> to the matching
/// <see cref="AbLegacyDataType"/>. Returns <c>false</c> for unparsable addresses.
/// </summary>
/// <remarks>
/// The mapping follows the file-letter table on
/// <see cref="AbLegacyAddress"/> doc comments:
/// N→Int, F→Float, B→Bit, L→Long, ST→String, T→TimerElement, C→CounterElement,
/// R→ControlElement, A→AnalogInt, S/I/O→Int (status / I/O bits resolve as Bit when
/// the address carries a <c>/N</c> bit suffix), PD→PidElement, MG→MessageElement,
/// PLS→PlsElement, BT→BlockTransferElement, function-file letters (RTC/HSC/etc.) →
/// MicroLogixFunctionFile.
/// </remarks>
public static bool TryResolveDataType(string address, out AbLegacyDataType dataType)
{
dataType = AbLegacyDataType.Int;
// Use Slc500 as the parser family — it accepts every common letter the importer
// sees in the wild. Family-specific gating (PLC-5 octal I:/O:, PD/MG/PLS/BT) only
// matters for runtime addressing, not for shape classification at import time.
var parsed = AbLegacyAddress.TryParse(address, PlcFamilies.AbLegacyPlcFamily.Plc5)
?? AbLegacyAddress.TryParse(address, PlcFamilies.AbLegacyPlcFamily.Slc500)
?? AbLegacyAddress.TryParse(address, PlcFamilies.AbLegacyPlcFamily.MicroLogix);
if (parsed is null) return false;
var letter = parsed.FileLetter;
// Bit-within-word references on N/L/I/O/S files surface as Bit regardless of the
// base file type. B-file references with no bit suffix are rare in real exports
// but still classify as Bit (the wire-level element is a single word — Rockwell
// convention is one bool per word).
if (parsed.BitIndex is not null)
{
dataType = AbLegacyDataType.Bit;
return true;
}
dataType = letter switch
{
"N" => AbLegacyDataType.Int,
"F" => AbLegacyDataType.Float,
"B" => AbLegacyDataType.Bit,
"L" => AbLegacyDataType.Long,
"ST" => AbLegacyDataType.String,
"T" => AbLegacyDataType.TimerElement,
"C" => AbLegacyDataType.CounterElement,
"R" => AbLegacyDataType.ControlElement,
"A" => AbLegacyDataType.AnalogInt,
"I" or "O" or "S" => AbLegacyDataType.Int,
"PD" => AbLegacyDataType.PidElement,
"MG" => AbLegacyDataType.MessageElement,
"PLS" => AbLegacyDataType.PlsElement,
"BT" => AbLegacyDataType.BlockTransferElement,
_ when AbLegacyAddress.IsFunctionFileLetter(letter) => AbLegacyDataType.MicroLogixFunctionFile,
_ => AbLegacyDataType.Int,
};
return true;
}
private static string SafeField(IReadOnlyList<string> fields, int idx) =>
idx >= 0 && idx < fields.Count ? fields[idx] : string.Empty;
/// <summary>
/// RFC 4180-ish CSV splitter — quoted fields, doubled-quote escape, embedded comma
/// inside quoted fields. Avoids a third-party CSV dependency for a five-column
/// parser.
/// </summary>
internal static List<string> SplitCsv(string line)
{
var fields = new List<string>();
var sb = new StringBuilder(line.Length);
var inQuotes = false;
for (var i = 0; i < line.Length; i++)
{
var c = line[i];
if (inQuotes)
{
if (c == '"')
{
// Doubled quote inside a quoted field is a literal `"`; otherwise the
// quote terminates the quoted segment.
if (i + 1 < line.Length && line[i + 1] == '"')
{
sb.Append('"');
i++;
}
else
{
inQuotes = false;
}
}
else
{
sb.Append(c);
}
}
else
{
switch (c)
{
case '"':
inQuotes = true;
break;
case ',':
fields.Add(sb.ToString());
sb.Clear();
break;
default:
sb.Append(c);
break;
}
}
}
fields.Add(sb.ToString());
return fields;
}
}

View File

@@ -22,6 +22,10 @@
Decision #41 — AbLegacy split from AbCip since PCCC addressing (file-based N7:0) and
Logix addressing (symbolic Motor1.Speed) pull the abstraction in incompatible directions. -->
<PackageReference Include="libplctag" Version="1.5.2"/>
<!-- ablegacy-11 / #254 — RsLogixSymbolImport logs warnings for malformed CSV rows
via ILogger so import-time issues surface in Serilog without making the importer
throw. Abstractions only — runtime sink is the host's responsibility. -->
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0"/>
</ItemGroup>
<ItemGroup>