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; /// /// Materialises entries from RSLogix 500 / 5 /// "Database Export" CSV. The expected column shape is /// Symbol,Address,Description,DataType,Scope — 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 Address; it is left in the schema for /// forward-compatibility with editor tools that prefer to drive the type explicitly). /// /// /// /// The parser is deliberately tolerant: header row + comment lines (starting with /// ; or #) are skipped silently, headers are matched case-insensitively, /// and quoted fields handle embedded commas the way RFC 4180 prescribes ("foo,bar" /// → foo,bar; doubled quotes inside a quoted field collapse to a single /// literal quote). /// /// /// Type resolution defers to + /// so the whole "what kind of file is N7?" knowledge /// lives in one place. Function-file (RTC, HSC, …) and structure-file /// (PD, MG, PLS, BT) prefixes are accepted but parsed /// conditionally on ; 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 needs to classify. /// /// /// surfaces only when /// is false — the default permissive /// path logs a warning per malformed row and bumps the SkippedCount / /// ErrorCount totals on . /// /// public sealed class RsLogixSymbolImport : IRsLogixImporter { private readonly ILogger _logger; public RsLogixSymbolImport() : this(NullLogger.Instance) { } public RsLogixSymbolImport(ILogger logger) { _logger = logger ?? NullLogger.Instance; } /// 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(); 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); } /// /// Resolve a PCCC to the matching /// . Returns false for unparsable addresses. /// /// /// The mapping follows the file-letter table on /// 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 /N bit suffix), PD→PidElement, MG→MessageElement, /// PLS→PlsElement, BT→BlockTransferElement, function-file letters (RTC/HSC/etc.) → /// MicroLogixFunctionFile. /// 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 fields, int idx) => idx >= 0 && idx < fields.Count ? fields[idx] : string.Empty; /// /// 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. /// internal static List SplitCsv(string line) { var fields = new List(); 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; } }