4e8df38bb2
Closes #254
326 lines
14 KiB
C#
326 lines
14 KiB
C#
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;
|
|
}
|
|
}
|