Auto: s7-d1 — TIA Portal CSV + STEP 7 Classic AWL symbol import

Closes #299
This commit is contained in:
Joseph Doherty
2026-04-26 06:32:18 -04:00
parent ac3fd45cc6
commit a908dff7b5
20 changed files with 2526 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
using System.IO;
using System.Text.Json;
using CliFx;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
/// <summary>
/// PR-S7-D1 / #299 — read a TIA Portal CSV ("Show all tags" export) or a STEP 7 Classic
/// AWL declaration file and emit either an <c>appsettings.json</c> tag fragment or a
/// summary line. Mirrors the AB Legacy <c>import-rslogix</c> command in shape.
/// </summary>
[Command("import-symbols", Description =
"Read a TIA Portal CSV symbol export or STEP 7 Classic AWL file and emit a JSON tag " +
"fragment for appsettings.json. UDT-typed symbols import as placeholders until PR-S7-D2.")]
public sealed class ImportSymbolsCommand : ICommand
{
[CommandOption("file", 'f', Description =
"Path to the symbol export. CSV or .AWL — see --format.",
IsRequired = true)]
public string File { get; init; } = default!;
[CommandOption("format", Description =
"Source format: 'tia' (TIA Portal CSV — Name,Path,Data type,Logical address,…) or " +
"'awl' (STEP 7 Classic VAR_GLOBAL + DATA_BLOCK declarations). Default: 'tia'.")]
public string Format { get; init; } = "tia";
[CommandOption("device", 'd', Description =
"Optional documentation tag for the device the imported symbols belong to. The S7 driver " +
"ties tags to the driver instance (one Host per instance), so the value is currently emitted " +
"as a comment-only annotation in the summary; reserved so the field exists for symmetry " +
"with the AB Legacy import CLI.")]
public string? Device { get; init; }
[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("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))
{
throw new CommandException($"Symbol export file not found: {File}", exitCode: 1);
}
var opts = new S7ImportOptions(
MaxRowsToImport: MaxRows,
IgnoreInvalid: !Strict);
var format = Format?.Trim().ToLowerInvariant() ?? "tia";
IS7SymbolImporter importer = format switch
{
"tia" or "csv" or null or "" => new TiaCsvImporter(),
"awl" or "step7" or "stl" => new AwlImporter(),
_ => throw new CommandException(
$"Unknown --format value '{Format}'. Use 'tia' or 'awl'.", exitCode: 2),
};
S7ImportResult result;
using (var stream = System.IO.File.OpenRead(File))
{
result = importer.Parse(stream, 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}, udt={result.UdtPlaceholderCount}).");
}
else
{
await console.Output.WriteLineAsync(payload);
}
}
/// <summary>
/// Serialise the imported tag list as a JSON fragment shaped like the
/// <c>S7DriverConfigDto</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(S7ImportResult result)
{
var tags = result.Tags.Select(t => new
{
Name = t.Name,
Address = t.Address,
DataType = t.DataType.ToString(),
Writable = t.Writable,
StringLength = t.StringLength,
}).ToArray();
var doc = new { Tags = tags };
return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
}
private static string FormatSummary(S7ImportResult result) =>
$"Imported {result.ParsedCount} tag(s), skipped {result.SkippedCount}, errors {result.ErrorCount}, udt-placeholders {result.UdtPlaceholderCount}.";
}

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.S7.SymbolImport;
using S7NetCpuType = global::S7.Net.CpuType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
@@ -102,6 +106,128 @@ public static class S7DriverFactoryExtensions
DeadbandAbsolute: t.DeadbandAbsolute,
DeadbandPercent: t.DeadbandPercent);
/// <summary>
/// PR-S7-D1 / #299 — append TIA Portal "Show all tags" CSV rows to
/// <paramref name="options"/> as <see cref="S7TagDefinition"/> entries. Returns a new
/// <see cref="S7DriverOptions"/> 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-symbols</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="S7ImportResult"/> counts surface on
/// <paramref name="result"/> for callers that want to assert "we got the row count
/// we expected".
/// </para>
/// <para>
/// UDT-typed rows materialise as placeholder tags (data type forced to
/// <see cref="S7DataType.Byte"/>); PR-S7-D2 will replace the placeholders with
/// proper UDT layout. See <c>docs/drivers/S7-TIA-Import.md</c>.
/// </para>
/// </remarks>
public static S7DriverOptions AddTiaCsvImport(
this S7DriverOptions options,
string path,
out S7ImportResult result,
S7ImportOptions? importOptions = null,
ILogger<TiaCsvImporter>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(path);
using var stream = File.OpenRead(path);
var importer = new TiaCsvImporter(logger ?? NullLogger<TiaCsvImporter>.Instance);
result = importer.Parse(stream, importOptions);
return MergeImportedTags(options, result.Tags);
}
/// <summary>
/// CLI-friendly overload that returns the <see cref="S7ImportResult"/> alongside the
/// modified options as a tuple. Mirrors <see cref="AddTiaCsvImport"/> but avoids the
/// <c>out</c> parameter for call sites that prefer pattern-matched destructuring.
/// </summary>
public static (S7DriverOptions Options, S7ImportResult Result) AddTiaCsvImportWithResult(
this S7DriverOptions options,
string path,
S7ImportOptions? importOptions = null,
ILogger<TiaCsvImporter>? logger = null)
{
var updated = options.AddTiaCsvImport(path, out var result, importOptions, logger);
return (updated, result);
}
/// <summary>
/// PR-S7-D1 / #299 — append STEP 7 Classic AWL <c>VAR_GLOBAL</c> + <c>DATA_BLOCK</c>
/// declarations to <paramref name="options"/> as <see cref="S7TagDefinition"/> entries.
/// Best-effort heuristic — see <see cref="AwlImporter"/> for the position-based
/// addressing rules.
/// </summary>
public static S7DriverOptions AddAwlImport(
this S7DriverOptions options,
string path,
out S7ImportResult result,
S7ImportOptions? importOptions = null,
ILogger<AwlImporter>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(path);
using var stream = File.OpenRead(path);
var importer = new AwlImporter(logger ?? NullLogger<AwlImporter>.Instance);
result = importer.Parse(stream, importOptions);
return MergeImportedTags(options, result.Tags);
}
/// <summary>
/// CLI-friendly overload that returns the <see cref="S7ImportResult"/> alongside the
/// modified options as a tuple. Mirrors <see cref="AddAwlImport"/>.
/// </summary>
public static (S7DriverOptions Options, S7ImportResult Result) AddAwlImportWithResult(
this S7DriverOptions options,
string path,
S7ImportOptions? importOptions = null,
ILogger<AwlImporter>? logger = null)
{
var updated = options.AddAwlImport(path, out var result, importOptions, logger);
return (updated, result);
}
/// <summary>
/// Concatenate <paramref name="imported"/> onto <paramref name="options"/>.<see cref="S7DriverOptions.Tags"/>
/// and return a new options object with every other field untouched. The importers
/// are additive so hand-edited Tags rows (e.g., system-status fields not surfaced by
/// the TIA / AWL export) keep sitting alongside the bulk-imported symbol rows.
/// </summary>
private static S7DriverOptions MergeImportedTags(
S7DriverOptions options, IReadOnlyList<S7TagDefinition> imported)
{
var merged = new List<S7TagDefinition>(options.Tags.Count + imported.Count);
merged.AddRange(options.Tags);
merged.AddRange(imported);
return new S7DriverOptions
{
Host = options.Host,
Port = options.Port,
CpuType = options.CpuType,
Rack = options.Rack,
Slot = options.Slot,
Timeout = options.Timeout,
Tags = merged,
Probe = options.Probe,
BlockCoalescingGapBytes = options.BlockCoalescingGapBytes,
TsapMode = options.TsapMode,
LocalTsap = options.LocalTsap,
RemoteTsap = options.RemoteTsap,
ScanGroupIntervals = options.ScanGroupIntervals,
};
}
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,434 @@
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
/// <summary>
/// Best-effort AWL (Anweisungsliste / STL — Statement List) importer for legacy STEP 7
/// Classic projects. Recognises two block types:
/// <list type="bullet">
/// <item><c>VAR_GLOBAL</c> … <c>END_VAR</c> — global memory area declarations.
/// Each entry maps to a sequential <c>M{B|W|D}{offset}</c> address based on
/// declaration order.</item>
/// <item><c>DATA_BLOCK "name"</c> … <c>END_DATA_BLOCK</c> — DB declarations.
/// Each field maps to a <c>DB{n}.DB{B|W|D}{offset}</c> address based on
/// declaration order; the DB number is parsed from the <c>DATA_BLOCK</c>
/// line's <c>DB</c> keyword (e.g. <c>DATA_BLOCK DB1</c>).</item>
/// </list>
/// </summary>
/// <remarks>
/// <para>
/// <b>Position-based addressing is heuristic.</b> Real STEP 7 Classic projects
/// carry exact byte offsets in the .gr8 / .awl emitted symbol table, but a hand-
/// exported AWL file omits them. The importer assumes:
/// <list type="bullet">
/// <item>BOOL → 1 byte (rounded up to byte alignment)</item>
/// <item>BYTE / SINT / USINT → 1 byte</item>
/// <item>INT / WORD / UINT → 2 bytes</item>
/// <item>DINT / DWORD / UDINT / REAL → 4 bytes</item>
/// <item>LREAL / LINT / ULINT → 8 bytes</item>
/// <item>STRING — sized by <c>STRING[N]</c> if specified, else 256 (S7 default + 2-byte header)</item>
/// </list>
/// A site needing exact offsets should drive its symbol import from the TIA Portal
/// CSV path instead — <see cref="TiaCsvImporter"/> takes the offsets verbatim from
/// the export.
/// </para>
/// <para>
/// Comments (<c>(* ... *)</c> block, <c>// ...</c> line) are stripped before
/// declaration parsing. Initial-value clauses (<c>:= 0</c>) are recognised and
/// discarded. Multi-line declarations (a single var split across lines) are
/// supported because the parser scans the entire <c>VAR_GLOBAL</c> body as a
/// token stream.
/// </para>
/// </remarks>
public sealed class AwlImporter : IS7SymbolImporter
{
private readonly ILogger<AwlImporter> _logger;
public AwlImporter() : this(NullLogger<AwlImporter>.Instance) { }
public AwlImporter(ILogger<AwlImporter> logger)
{
_logger = logger ?? NullLogger<AwlImporter>.Instance;
}
/// <inheritdoc />
public S7ImportResult Parse(Stream stream, S7ImportOptions? options = null)
{
ArgumentNullException.ThrowIfNull(stream);
var opts = options ?? new S7ImportOptions();
using var reader = new StreamReader(stream, Encoding.UTF8,
detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
var raw = reader.ReadToEnd();
// Strip comments first — keeps the block-walker simpler.
var stripped = StripComments(raw);
var tags = new List<S7TagDefinition>();
var parsed = 0;
var skipped = 0;
var errors = 0;
var udtPlaceholders = 0;
// VAR_GLOBAL block — assign sequential MW offsets.
foreach (var (body, _) in ExtractBlocks(stripped, "VAR_GLOBAL", "END_VAR"))
{
var globalOffset = 0;
foreach (var decl in ExtractDeclarations(body))
{
if (opts.MaxRowsToImport is int cap && parsed >= cap)
{
_logger.LogWarning(
"AWL VAR_GLOBAL hit MaxRowsToImport={Cap}; remaining declarations skipped.", cap);
break;
}
try
{
var (s7Type, sizeBytes, isUdt) = ResolveType(decl.TypeName);
if (isUdt)
{
// UDT placeholder — synthesise a Byte tag at the next aligned offset
// so the operator at least sees the symbol surface in the Admin UI.
globalOffset = AlignTo(globalOffset, 1);
var placeholderAddr = $"MB{globalOffset}";
tags.Add(new S7TagDefinition(
Name: decl.Name,
Address: placeholderAddr,
DataType: S7DataType.Byte,
Writable: false));
udtPlaceholders++;
parsed++;
globalOffset += 1;
_logger.LogInformation(
"AWL VAR_GLOBAL '{Name}' imported as UDT placeholder ({TypeName}). [UDT placeholder — wait for D2]",
decl.Name, decl.TypeName);
continue;
}
if (s7Type is null)
{
if (!opts.IgnoreInvalid)
{
throw new InvalidDataException(
$"AWL VAR_GLOBAL declaration '{decl.Name} : {decl.TypeName}' has unrecognised type.");
}
errors++;
_logger.LogWarning(
"AWL VAR_GLOBAL '{Name}' skipped — unrecognised type '{TypeName}'.",
decl.Name, decl.TypeName);
continue;
}
globalOffset = AlignTo(globalOffset, sizeBytes);
var address = BuildMAddress(s7Type.Value, globalOffset);
tags.Add(new S7TagDefinition(
Name: decl.Name,
Address: address,
DataType: s7Type.Value,
Writable: true));
parsed++;
globalOffset += sizeBytes;
}
catch (InvalidDataException) when (opts.IgnoreInvalid)
{
errors++;
_logger.LogWarning("AWL VAR_GLOBAL declaration '{Name}' skipped — invalid data.", decl.Name);
}
catch (Exception ex) when (opts.IgnoreInvalid)
{
errors++;
_logger.LogWarning(ex, "AWL VAR_GLOBAL declaration '{Name}' skipped — parser threw.", decl.Name);
}
}
}
// DATA_BLOCK "name" / DBn — assign sequential DBW offsets per block.
foreach (var (body, header) in ExtractBlocks(stripped, "DATA_BLOCK", "END_DATA_BLOCK"))
{
var dbNumber = ExtractDbNumber(header);
if (dbNumber is null)
{
_logger.LogWarning(
"AWL DATA_BLOCK header '{Header}' missing DBn — entries skipped.",
header.Trim());
continue;
}
var dbOffset = 0;
// The DB body in real STEP 7 wraps the field declarations in another VAR_TEMP /
// STRUCT block. Walk every nested declaration via the same regex.
foreach (var decl in ExtractDeclarations(body))
{
if (opts.MaxRowsToImport is int cap && parsed >= cap)
{
_logger.LogWarning(
"AWL DATA_BLOCK hit MaxRowsToImport={Cap}; remaining declarations skipped.", cap);
break;
}
try
{
var (s7Type, sizeBytes, isUdt) = ResolveType(decl.TypeName);
if (isUdt)
{
dbOffset = AlignTo(dbOffset, 1);
var placeholderAddr = $"DB{dbNumber.Value}.DBB{dbOffset}";
tags.Add(new S7TagDefinition(
Name: decl.Name,
Address: placeholderAddr,
DataType: S7DataType.Byte,
Writable: false));
udtPlaceholders++;
parsed++;
dbOffset += 1;
continue;
}
if (s7Type is null)
{
if (!opts.IgnoreInvalid)
{
throw new InvalidDataException(
$"AWL DATA_BLOCK DB{dbNumber} declaration '{decl.Name} : {decl.TypeName}' has unrecognised type.");
}
errors++;
_logger.LogWarning(
"AWL DB{DbNumber} '{Name}' skipped — unrecognised type '{TypeName}'.",
dbNumber.Value, decl.Name, decl.TypeName);
continue;
}
dbOffset = AlignTo(dbOffset, sizeBytes);
var address = BuildDbAddress(dbNumber.Value, s7Type.Value, dbOffset);
tags.Add(new S7TagDefinition(
Name: decl.Name,
Address: address,
DataType: s7Type.Value,
Writable: true));
parsed++;
dbOffset += sizeBytes;
}
catch (InvalidDataException) when (opts.IgnoreInvalid)
{
errors++;
_logger.LogWarning("AWL DATA_BLOCK declaration '{Name}' skipped — invalid data.", decl.Name);
}
catch (Exception ex) when (opts.IgnoreInvalid)
{
errors++;
_logger.LogWarning(ex, "AWL DATA_BLOCK declaration '{Name}' skipped — parser threw.", decl.Name);
}
}
}
return new S7ImportResult(tags, parsed, skipped, errors, udtPlaceholders);
}
/// <summary>
/// Strip both <c>(* ... *)</c> block comments and <c>// ...</c> line comments.
/// Block comments don't nest in AWL.
/// </summary>
internal static string StripComments(string input)
{
// Block (* ... *)
var noBlock = Regex.Replace(input, @"\(\*.*?\*\)", " ", RegexOptions.Singleline);
// Line // ...
var noLine = Regex.Replace(noBlock, @"//[^\r\n]*", string.Empty);
return noLine;
}
/// <summary>
/// Yield (body, header-line) pairs for every block bounded by
/// <paramref name="startKeyword"/>/<paramref name="endKeyword"/>. The header line
/// is everything between the start keyword and the first newline, useful for
/// extracting block names / DB numbers.
/// </summary>
internal static IEnumerable<(string Body, string Header)> ExtractBlocks(
string input, string startKeyword, string endKeyword)
{
var idx = 0;
while (idx < input.Length)
{
var startMatch = Regex.Match(input.Substring(idx),
$@"\b{Regex.Escape(startKeyword)}\b", RegexOptions.IgnoreCase);
if (!startMatch.Success) yield break;
var blockStart = idx + startMatch.Index;
// Header = from after the keyword to the first newline.
var headerStart = blockStart + startMatch.Length;
var newlineIdx = input.IndexOf('\n', headerStart);
if (newlineIdx < 0) newlineIdx = input.Length;
var header = input.Substring(headerStart, newlineIdx - headerStart);
// End keyword.
var endIdx = input.IndexOf(endKeyword, newlineIdx, StringComparison.OrdinalIgnoreCase);
if (endIdx < 0)
{
yield break;
}
var body = input.Substring(newlineIdx, endIdx - newlineIdx);
yield return (body, header);
idx = endIdx + endKeyword.Length;
}
}
/// <summary>
/// Extract <c>name : TYPE [ := initial ];</c> declarations from a block body.
/// Permissive — the regex anchors on the colon-and-type pattern so accompanying
/// STRUCT / VAR_TEMP wrappers don't have to be parsed structurally.
/// </summary>
internal static IEnumerable<AwlDeclaration> ExtractDeclarations(string body)
{
// name : TYPE [optional := init];
// Captures: 1=name, 2=type (everything up to := or ;)
// Type may start with " (UDT reference like "MyType"), [ (Array bracket), or a
// bare letter for primitive / struct keywords.
var rx = new Regex(
@"([A-Za-z_][A-Za-z0-9_]*)\s*:\s*((?:[A-Za-z_""\[][\w\[\]\.\,\s\""]*?))\s*(?::=\s*[^;]+)?;",
RegexOptions.Compiled);
// Skip well-known keywords at column-0 like STRUCT / END_STRUCT / VAR_TEMP / etc.
// by filtering on captured names that match an AWL reserved word.
foreach (Match m in rx.Matches(body))
{
var name = m.Groups[1].Value.Trim();
var typeName = m.Groups[2].Value.Trim().TrimEnd(';').Trim();
if (IsReservedAwlWord(name)) continue;
if (string.IsNullOrEmpty(typeName)) continue;
yield return new AwlDeclaration(name, typeName);
}
}
private static bool IsReservedAwlWord(string s) =>
s.ToUpperInvariant() switch
{
"VAR" or "VAR_INPUT" or "VAR_OUTPUT" or "VAR_IN_OUT" or "VAR_TEMP" or
"VAR_GLOBAL" or "END_VAR" or "STRUCT" or "END_STRUCT" or
"DATA_BLOCK" or "END_DATA_BLOCK" or
"TYPE" or "END_TYPE" or
"TITLE" or "VERSION" or "BEGIN" or "ORGANIZATION_BLOCK" or "FUNCTION_BLOCK" or
"FUNCTION" or "END_FUNCTION" or "END_FUNCTION_BLOCK" or "END_ORGANIZATION_BLOCK" => true,
_ => false,
};
/// <summary>
/// Pull the DB number from a <c>DATA_BLOCK</c> header. Accepts both
/// <c>DATA_BLOCK DB1</c> (bare keyword) and <c>DATA_BLOCK "MyDB"</c> with a follow-on
/// <c>// DB1</c> comment-style number — for the v1 we only support the bare
/// <c>DBn</c> form because that's what stock STEP 7 emits.
/// </summary>
internal static int? ExtractDbNumber(string header)
{
var m = Regex.Match(header, @"\bDB\s*(\d+)\b", RegexOptions.IgnoreCase);
if (!m.Success) return null;
return int.Parse(m.Groups[1].Value);
}
/// <summary>
/// Map an AWL type name to (S7 data type, on-wire byte size, isUdt). UDTs and
/// unknown types return (<c>null</c>, <c>0</c>, <c>true</c>) — caller handles them
/// as placeholders.
/// </summary>
internal static (S7DataType? S7Type, int SizeBytes, bool IsUdt) ResolveType(string typeName)
{
var t = typeName.Trim().Trim('"').ToUpperInvariant();
// STRING[N] → S7 String with N+2 byte length.
var strMatch = Regex.Match(t, @"^STRING\s*\[\s*(\d+)\s*\]");
if (strMatch.Success)
{
var len = int.Parse(strMatch.Groups[1].Value);
return (S7DataType.String, len + 2, false);
}
if (t == "STRING") return (S7DataType.String, 256, false);
// Array of UDT or array of primitive — placeholder for now (D2 territory).
if (t.StartsWith("ARRAY", StringComparison.Ordinal))
{
return (null, 0, true);
}
return t switch
{
"BOOL" => (S7DataType.Bool, 1, false),
"BYTE" or "SINT" or "USINT" or "CHAR" => (S7DataType.Byte, 1, false),
"WORD" or "UINT" => (S7DataType.UInt16, 2, false),
"INT" => (S7DataType.Int16, 2, false),
"DWORD" or "UDINT" => (S7DataType.UInt32, 4, false),
"DINT" => (S7DataType.Int32, 4, false),
"REAL" => (S7DataType.Float32, 4, false),
"LREAL" => (S7DataType.Float64, 8, false),
"LINT" => (S7DataType.Int64, 8, false),
"ULINT" or "LWORD" => (S7DataType.UInt64, 8, false),
"TIME" => (S7DataType.Time, 4, false),
"DATE" => (S7DataType.Date, 2, false),
"TOD" or "TIME_OF_DAY" => (S7DataType.TimeOfDay, 4, false),
"DT" or "DATE_AND_TIME" => (S7DataType.DateAndTime, 8, false),
"DTL" => (S7DataType.Dtl, 12, false),
"S5TIME" => (S7DataType.S5Time, 2, false),
"STRUCT" => (null, 0, true),
// Anything else is treated as a UDT reference — surfaces as a placeholder.
_ => (null, 0, IsLikelyUdtName(typeName)),
};
}
private static bool IsLikelyUdtName(string raw)
{
var s = raw.Trim();
// Quoted identifier — TIA / STEP 7 wrap UDT references in double quotes.
if (s.StartsWith('"') && s.EndsWith('"')) return true;
// Bare CamelCase identifier we don't recognise as a primitive — treat as UDT.
if (s.Length > 0 && (char.IsLetter(s[0]) || s[0] == '_')) return true;
return false;
}
/// <summary>
/// Round <paramref name="offset"/> up to a multiple of <paramref name="size"/>.
/// S7 alignment rule: 16-bit values align to 2-byte boundaries; 32/64-bit values
/// align to 2-byte boundaries (NOT 4 — STEP 7 packs DBs at word granularity).
/// We use min(size, 2) for the alignment factor.
/// </summary>
internal static int AlignTo(int offset, int size)
{
var align = size <= 1 ? 1 : 2;
var rem = offset % align;
if (rem == 0) return offset;
return offset + (align - rem);
}
private static string BuildMAddress(S7DataType type, int offset) => type switch
{
S7DataType.Bool => $"M{offset}.0",
S7DataType.Byte => $"MB{offset}",
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.WChar or S7DataType.Date or S7DataType.S5Time
=> $"MW{offset}",
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32 or S7DataType.Time or S7DataType.TimeOfDay
=> $"MD{offset}",
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 or S7DataType.DateAndTime
=> $"MLD{offset}",
_ => $"MB{offset}",
};
private static string BuildDbAddress(int dbNumber, S7DataType type, int offset) => type switch
{
S7DataType.Bool => $"DB{dbNumber}.DBX{offset}.0",
S7DataType.Byte => $"DB{dbNumber}.DBB{offset}",
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.WChar or S7DataType.Date or S7DataType.S5Time
=> $"DB{dbNumber}.DBW{offset}",
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32 or S7DataType.Time or S7DataType.TimeOfDay
=> $"DB{dbNumber}.DBD{offset}",
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 or S7DataType.DateAndTime
=> $"DB{dbNumber}.DBLD{offset}",
S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.Dtl
=> $"DB{dbNumber}.DBB{offset}",
_ => $"DB{dbNumber}.DBB{offset}",
};
/// <summary>One <c>name : TYPE</c> declaration extracted from an AWL block body.</summary>
internal sealed record AwlDeclaration(string Name, string TypeName);
}

View File

@@ -0,0 +1,37 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
/// <summary>
/// Materialises <see cref="S7TagDefinition"/> entries from a vendor-supplied symbol
/// export — TIA Portal CSV (<see cref="TiaCsvImporter"/>) or STEP 7 Classic AWL
/// declaration text (<see cref="AwlImporter"/>). The interface exists so additional
/// formats (e.g. STEP 7 / TIA Portal native binary, openness API, …) can slot in
/// later without reshaping the call sites.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="Parse"/> never throws on per-row parse errors when
/// <see cref="S7ImportOptions.IgnoreInvalid"/> is <c>true</c> (default) — malformed
/// rows are skipped with a structured warning logged via the importer's
/// <c>ILogger</c>, and the counters surface on <see cref="S7ImportResult"/>.
/// With <see cref="S7ImportOptions.IgnoreInvalid"/> set to <c>false</c> the first
/// malformed row throws <see cref="System.IO.InvalidDataException"/>.
/// </para>
/// <para>
/// UDT-typed symbols import as <em>placeholders</em> — the resulting
/// <see cref="S7TagDefinition"/> is well-formed enough to drop into the driver's
/// tag list but it carries the comment marker <c>[UDT placeholder — wait for D2]</c>
/// and an <see cref="S7DataType.Byte"/> data type as a non-functional default.
/// PR-S7-D2 will replace the placeholder with proper UDT layout once the symbol
/// table covers nested struct fields.
/// </para>
/// </remarks>
public interface IS7SymbolImporter
{
/// <summary>
/// Read the entire <paramref name="stream"/> and emit one
/// <see cref="S7TagDefinition"/> per recognised symbol row.
/// </summary>
/// <param name="stream">Open, readable stream over the export. Caller owns it.</param>
/// <param name="options">Filter + safety knobs; <c>null</c> ≡ default options.</param>
S7ImportResult Parse(Stream stream, S7ImportOptions? options = null);
}

View File

@@ -0,0 +1,26 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
/// <summary>
/// Options that drive an <see cref="IS7SymbolImporter"/> run. Two knobs:
/// <list type="bullet">
/// <item>
/// <see cref="MaxRowsToImport"/> — defensive cap; the parser stops past this
/// count and emits a warning so a stray multi-thousand-row export doesn't
/// silently hammer the host process at startup.
/// </item>
/// <item>
/// <see cref="IgnoreInvalid"/> — default <c>true</c>: per-row parse errors are
/// logged and skipped; counter increments. When <c>false</c> the first
/// malformed row surfaces as <see cref="System.IO.InvalidDataException"/> for
/// fail-fast CI lint paths.
/// </item>
/// </list>
/// </summary>
/// <remarks>
/// UDT placeholders are <em>not</em> governed by <see cref="IgnoreInvalid"/> — they're
/// a deliberate import outcome (track the symbol so it lands in the Admin UI tag list
/// even before D2 ships proper UDT layout) and always succeed regardless of the flag.
/// </remarks>
public sealed record S7ImportOptions(
int? MaxRowsToImport = null,
bool IgnoreInvalid = true);

View File

@@ -0,0 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
/// <summary>
/// Outcome of a single <see cref="IS7SymbolImporter"/> run. <see cref="Tags"/> carries
/// the imported tag definitions ready to drop into <c>S7DriverOptions.Tags</c>;
/// <see cref="ParsedCount"/>, <see cref="SkippedCount"/>, <see cref="ErrorCount"/>, and
/// <see cref="UdtPlaceholderCount"/> give the operator a single line of telemetry
/// ("imported 142 / skipped 3 / errored 0 / udt 5") suitable for either a CLI summary
/// or a startup-time log line.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="ParsedCount"/> includes UDT placeholders — placeholders count as
/// imported tags (they materialise as <see cref="S7TagDefinition"/> rows the driver
/// can list in the Admin UI), they're just non-functional until PR-S7-D2 lands.
/// <see cref="UdtPlaceholderCount"/> is a sub-count operators can compare against
/// <see cref="ParsedCount"/> to spot how much of the import is still placeholder.
/// </para>
/// </remarks>
public sealed record S7ImportResult(
IReadOnlyList<S7TagDefinition> Tags,
int ParsedCount,
int SkippedCount,
int ErrorCount,
int UdtPlaceholderCount);

View File

@@ -0,0 +1,514 @@
using System.Globalization;
using System.IO;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
/// <summary>
/// Materialises <see cref="S7TagDefinition"/> entries from a TIA Portal "Show all
/// tags" CSV export. The expected column shape (TIA Portal v15+ default) is
/// <c>Name,Path,Data type,Logical address,Comment,Hmi accessible,Hmi visible,
/// Hmi writeable,Length</c> — only <c>Name</c> + <c>Logical address</c> are strictly
/// required; everything else is optional metadata. Older TIA Portal versions emit a
/// subset (e.g. <c>Name,Address,Data type,Comment</c> for v13) — the parser maps
/// whatever subset the header row carries and tolerates missing optional columns.
/// </summary>
/// <remarks>
/// <para>
/// <b>Locale detection</b>: TIA Portal honours the Windows display locale when
/// writing CSV. A DE-locale install emits comma-decimal addresses
/// (<c>%MW0,5</c>) and <c>WAHR</c> / <c>FALSCH</c> for the boolean HMI columns.
/// The importer detects DE locale by sniffing the first data row's address — if
/// it contains a digit-comma-digit sequence the parser interprets the comma as
/// the decimal separator and rewrites the address to en-US shape (<c>%MW0.5</c>)
/// before parsing. Boolean column values are recognised in both languages
/// (<c>true/false/wahr/falsch/yes/no/ja/nein</c>, case-insensitive).
/// </para>
/// <para>
/// <b>UDT placeholders</b>: rows whose <c>Data type</c> is <c>Struct</c> or names
/// a user-defined type (<c>"udt_name"</c> with the surrounding quotes that TIA
/// emits) import as a non-functional placeholder tag — the result still lands in
/// the driver options so it shows up in the Admin UI tag list, but the data type
/// is forced to <see cref="S7DataType.Byte"/> and the comment carries the
/// marker <c>[UDT placeholder — wait for D2]</c>. <see cref="S7ImportResult.UdtPlaceholderCount"/>
/// tracks how many of the imported tags fell into this bucket.
/// </para>
/// <para>
/// <b>HMI-accessible filter</b>: rows whose <c>Hmi accessible</c> column is
/// explicitly <c>false</c> / <c>FALSCH</c> / <c>nein</c> are skipped — these are
/// internal symbols TIA shows in the editor but doesn't expose to client
/// interfaces. Missing or blank <c>Hmi accessible</c> defaults to <c>true</c>
/// (older TIA Portal versions don't emit the column at all).
/// </para>
/// </remarks>
public sealed class TiaCsvImporter : IS7SymbolImporter
{
private readonly ILogger<TiaCsvImporter> _logger;
public TiaCsvImporter() : this(NullLogger<TiaCsvImporter>.Instance) { }
public TiaCsvImporter(ILogger<TiaCsvImporter> logger)
{
_logger = logger ?? NullLogger<TiaCsvImporter>.Instance;
}
/// <inheritdoc />
public S7ImportResult Parse(Stream stream, S7ImportOptions? options = null)
{
ArgumentNullException.ThrowIfNull(stream);
var opts = options ?? new S7ImportOptions();
var tags = new List<S7TagDefinition>();
var parsed = 0;
var skipped = 0;
var errors = 0;
var udtPlaceholders = 0;
using var reader = new StreamReader(stream, Encoding.UTF8,
detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
int? nameIdx = null;
int? addressIdx = null;
int? dataTypeIdx = null;
int? commentIdx = null;
int? hmiAccessibleIdx = null;
int? lengthIdx = null;
var headerSeen = false;
var deLocale = false;
var lineNumber = 0;
// Sniff: TIA emits ';' as the field separator under DE locale (because comma is
// the decimal separator). en-US uses ','. Detect from the first non-blank line by
// counting which separator yields more fields when we tokenise the header.
var allLines = new List<string>();
string? line;
while ((line = reader.ReadLine()) is not null)
{
allLines.Add(line);
}
char separator = DetectSeparator(allLines);
foreach (var rawLine in allLines)
{
lineNumber++;
if (string.IsNullOrWhiteSpace(rawLine)) continue;
var trimmed = rawLine.TrimStart();
// Comment-only safety net — most TIA exports don't carry comments, but
// operators sometimes annotate fixtures by hand. Treat lines starting
// with `#` as comments. `;` is ambiguous (it's the DE-locale separator)
// so we explicitly do NOT treat it as a comment marker.
if (trimmed.StartsWith('#'))
{
continue;
}
var fields = SplitCsv(rawLine, separator);
if (fields.Count == 0) continue;
if (!headerSeen)
{
for (var i = 0; i < fields.Count; i++)
{
var header = fields[i].Trim().Trim('"').ToLowerInvariant();
switch (header)
{
case "name": nameIdx = i; break;
case "logical address":
case "address": addressIdx = i; break;
case "data type":
case "datatype":
case "type": dataTypeIdx = i; break;
case "comment": commentIdx = i; break;
case "hmi accessible":
case "hmi-accessible": hmiAccessibleIdx = i; break;
case "length": lengthIdx = i; break;
}
}
if (nameIdx is null || addressIdx is null)
{
throw new InvalidDataException(
$"TIA CSV header at line {lineNumber} is missing required Name or Logical address column. " +
$"Got: {string.Join(separator.ToString(), fields)}");
}
headerSeen = true;
continue;
}
// Locale sniff: examine the first data row's address column for a comma between
// digits, which only happens in DE locale (en-US uses '.' for the decimal point).
if (!deLocale && addressIdx is { } ai && fields.Count > ai)
{
var rawAddr = fields[ai].Trim().Trim('"');
if (LooksDeLocale(rawAddr))
{
deLocale = true;
_logger.LogInformation(
"TIA CSV locale auto-detected as DE (decimal-comma in address '{Address}' at line {LineNumber})",
rawAddr, lineNumber);
}
}
if (opts.MaxRowsToImport is int cap && parsed >= cap)
{
_logger.LogWarning(
"TIA CSV import hit MaxRowsToImport={Cap} at line {LineNumber}; remaining rows skipped.",
cap, lineNumber);
break;
}
try
{
var name = SafeField(fields, nameIdx!.Value).Trim().Trim('"');
var address = SafeField(fields, addressIdx!.Value).Trim().Trim('"');
var dataTypeStr = dataTypeIdx.HasValue ? SafeField(fields, dataTypeIdx.Value).Trim().Trim('"') : string.Empty;
var comment = commentIdx.HasValue ? SafeField(fields, commentIdx.Value).Trim().Trim('"') : null;
var hmiAccessible = hmiAccessibleIdx.HasValue
? ParseBoolColumn(SafeField(fields, hmiAccessibleIdx.Value), defaultValue: true)
: true;
var lengthStr = lengthIdx.HasValue ? SafeField(fields, lengthIdx.Value).Trim().Trim('"') : null;
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(address))
{
skipped++;
_logger.LogWarning(
"TIA CSV row at line {LineNumber} skipped — missing Name or Logical address (name='{Name}', address='{Address}').",
lineNumber, name, address);
continue;
}
if (!hmiAccessible)
{
skipped++;
continue;
}
// Strip the leading '%' that TIA always emits and rewrite DE-locale comma
// to '.' so the rest of the pipeline sees a single canonical address shape.
var normalised = NormaliseAddress(address, deLocale);
// Detect UDT placeholder before the strict address parser runs. UDTs in TIA
// typically address as DBn (whole DB) and don't carry a width suffix; the
// address parser would reject them.
var isUdtPlaceholder = IsUdtTypeName(dataTypeStr);
if (isUdtPlaceholder)
{
var placeholder = new S7TagDefinition(
Name: name,
Address: normalised,
DataType: S7DataType.Byte,
Writable: false);
tags.Add(placeholder);
udtPlaceholders++;
parsed++;
_logger.LogInformation(
"TIA CSV row at line {LineNumber} imported as UDT placeholder (Name='{Name}', DataType='{DataType}'). [UDT placeholder — wait for D2]",
lineNumber, name, dataTypeStr);
continue;
}
if (!TryResolveDataType(dataTypeStr, normalised, out var s7Type))
{
if (!opts.IgnoreInvalid)
{
throw new InvalidDataException(
$"TIA CSV row at line {lineNumber} has unrecognised Data type '{dataTypeStr}' for address '{address}'.");
}
errors++;
_logger.LogWarning(
"TIA CSV row at line {LineNumber} skipped — unrecognised Data type '{DataType}' for address '{Address}'.",
lineNumber, dataTypeStr, address);
continue;
}
// Validate the address syntax — strict so a typo in TIA's "Show all tags"
// export surfaces at import time, not as a misleading runtime read failure.
if (!S7AddressParser.TryParse(normalised, out _))
{
if (!opts.IgnoreInvalid)
{
throw new InvalidDataException(
$"TIA CSV row at line {lineNumber} has invalid S7 address '{normalised}'.");
}
errors++;
_logger.LogWarning(
"TIA CSV row at line {LineNumber} skipped — invalid S7 address '{Address}'.",
lineNumber, normalised);
continue;
}
var stringLength = 254;
if (s7Type == S7DataType.String && !string.IsNullOrWhiteSpace(lengthStr) &&
int.TryParse(lengthStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var len) &&
len > 0)
{
stringLength = len;
}
_ = comment; // currently parsed but unused — S7TagDefinition has no Comment field today.
tags.Add(new S7TagDefinition(
Name: name,
Address: normalised,
DataType: s7Type,
Writable: true,
StringLength: stringLength));
parsed++;
}
catch (InvalidDataException) when (opts.IgnoreInvalid)
{
errors++;
_logger.LogWarning("TIA CSV row at line {LineNumber} skipped — invalid data.", lineNumber);
}
catch (Exception ex) when (opts.IgnoreInvalid)
{
errors++;
_logger.LogWarning(ex, "TIA CSV row at line {LineNumber} skipped — parser threw.", lineNumber);
}
}
if (!headerSeen)
{
return new S7ImportResult([], 0, skipped, errors, udtPlaceholders);
}
return new S7ImportResult(tags, parsed, skipped, errors, udtPlaceholders);
}
/// <summary>
/// Detect the field separator a TIA CSV uses. en-US locale uses ','; DE locale
/// uses ';' (because ',' is the decimal separator). We sniff by counting the
/// occurrences in the first non-blank line — whichever character appears more
/// wins, defaulting to ',' for ties or empty inputs.
/// </summary>
internal static char DetectSeparator(IReadOnlyList<string> lines)
{
foreach (var l in lines)
{
if (string.IsNullOrWhiteSpace(l)) continue;
var commas = 0;
var semicolons = 0;
var inQuotes = false;
foreach (var c in l)
{
if (c == '"') inQuotes = !inQuotes;
else if (!inQuotes && c == ',') commas++;
else if (!inQuotes && c == ';') semicolons++;
}
if (semicolons > commas) return ';';
return ',';
}
return ',';
}
/// <summary>
/// Heuristic: a TIA address column carrying <c>%MW0,5</c>, <c>%DB1.DBD0,3</c>, or
/// similar digit-comma-digit pattern indicates DE locale. en-US would have written
/// <c>%MW0.5</c> instead.
/// </summary>
internal static bool LooksDeLocale(string address)
{
if (string.IsNullOrEmpty(address)) return false;
for (var i = 1; i + 1 < address.Length; i++)
{
if (address[i] == ',' &&
address[i - 1] >= '0' && address[i - 1] <= '9' &&
address[i + 1] >= '0' && address[i + 1] <= '9')
{
return true;
}
}
return false;
}
/// <summary>
/// Strip the leading '%' that TIA always emits, and rewrite DE-locale ',' to '.'
/// when the importer has detected DE locale. The S7AddressParser only understands
/// en-US-style decimals, so we canonicalise here.
/// </summary>
internal static string NormaliseAddress(string address, bool deLocale)
{
var s = address.Trim();
if (s.StartsWith('%')) s = s.Substring(1);
if (deLocale) s = s.Replace(',', '.');
return s;
}
/// <summary>
/// Recognise a TIA <c>Data type</c> column value as a UDT type name. TIA emits
/// UDT references as the bare name (sometimes wrapped in quotes), and the literal
/// string <c>Struct</c> for inline anonymous structs. Standard primitive type
/// names (Bool, Byte, Int, Real, …) are excluded — anything else is treated as
/// a UDT reference and imports as a placeholder.
/// </summary>
/// <remarks>
/// The CSV splitter strips the surrounding quotes that TIA wraps UDT references
/// in, so by the time this method runs the value is bare (e.g. <c>CookerSettings</c>
/// rather than <c>"CookerSettings"</c>). We use the primitive-name allow-list as
/// the discriminator and treat any unrecognised non-empty string as a UDT.
/// </remarks>
internal static bool IsUdtTypeName(string dataType)
{
if (string.IsNullOrWhiteSpace(dataType)) return false;
var stripped = dataType.Trim().Trim('"');
if (string.IsNullOrEmpty(stripped)) return false;
if (string.Equals(stripped, "Struct", StringComparison.OrdinalIgnoreCase)) return true;
// Array of UDT — `Array[0..9] of "MyUdt"` — also placeholder-worthy. Array of
// primitive (`Array[0..9] of Int`) is array, also placeholder until D2 ships
// proper array-of-UDT layout (PR-S7-D2 territory).
if (stripped.StartsWith("Array", StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Anything that isn't a recognised primitive is treated as a UDT reference.
return !IsPrimitiveType(stripped);
}
private static bool IsPrimitiveType(string raw)
{
return raw.Trim().ToLowerInvariant() switch
{
"bool" or "byte" or "char" or "wchar" or "word" or "dword" or "lword" or
"int" or "uint" or "dint" or "udint" or "lint" or "ulint" or "sint" or "usint" or
"real" or "lreal" or
"string" or "wstring" or
"date" or "time" or "time_of_day" or "tod" or "date_and_time" or "dt" or
"dtl" or "s5time" => true,
_ => false,
};
}
/// <summary>
/// Resolve a TIA <c>Data type</c> column value (or, when blank, the address's
/// size suffix) to the matching <see cref="S7DataType"/>. Returns <c>false</c> for
/// unparsable types.
/// </summary>
public static bool TryResolveDataType(string dataType, string address, out S7DataType s7Type)
{
s7Type = S7DataType.Byte;
var dt = (dataType ?? string.Empty).Trim().Trim('"').ToLowerInvariant();
// Direct mapping for the TIA primitive type names.
var direct = dt switch
{
"bool" => S7DataType.Bool,
"byte" or "sint" or "usint" => S7DataType.Byte,
"int" => S7DataType.Int16,
"word" or "uint" => S7DataType.UInt16,
"dint" => S7DataType.Int32,
"dword" or "udint" => S7DataType.UInt32,
"lint" => S7DataType.Int64,
"lword" or "ulint" => S7DataType.UInt64,
"real" => S7DataType.Float32,
"lreal" => S7DataType.Float64,
"string" => S7DataType.String,
"wstring" => S7DataType.WString,
"char" => S7DataType.Char,
"wchar" => S7DataType.WChar,
"date" => S7DataType.Date,
"time" => S7DataType.Time,
"time_of_day" or "tod" => S7DataType.TimeOfDay,
"date_and_time" or "dt" => S7DataType.DateAndTime,
"dtl" => S7DataType.Dtl,
"s5time" => S7DataType.S5Time,
_ => (S7DataType?)null,
};
if (direct.HasValue)
{
s7Type = direct.Value;
return true;
}
// Fallback: derive from the address size suffix. TIA exports sometimes leave the
// Data type column blank when the address's size letter (B/W/D/X) already pins it.
if (S7AddressParser.TryParse(address, out var parsed))
{
s7Type = parsed.Size switch
{
S7Size.Bit => S7DataType.Bool,
S7Size.Byte => S7DataType.Byte,
S7Size.Word => S7DataType.UInt16,
S7Size.DWord => S7DataType.UInt32,
S7Size.LWord => S7DataType.UInt64,
_ => S7DataType.Byte,
};
return true;
}
return false;
}
/// <summary>
/// Parse a TIA boolean column. Recognises both en-US (true/false/yes/no) and
/// DE-locale (wahr/falsch/ja/nein) values, plus the bare integer 0/1 some older
/// export tools emit. Empty / blank → <paramref name="defaultValue"/>.
/// </summary>
internal static bool ParseBoolColumn(string raw, bool defaultValue)
{
var s = raw?.Trim().Trim('"').ToLowerInvariant() ?? string.Empty;
return s switch
{
"" => defaultValue,
"true" or "wahr" or "yes" or "ja" or "1" => true,
"false" or "falsch" or "no" or "nein" or "0" => false,
_ => defaultValue,
};
}
private static string SafeField(IReadOnlyList<string> fields, int idx) =>
idx >= 0 && idx < fields.Count ? fields[idx] : string.Empty;
/// <summary>
/// RFC 4180-ish CSV splitter that accepts either ',' or ';' as the field
/// separator. Quoted fields, doubled-quote escape, embedded separators inside
/// quoted fields. Lifted from the AbLegacy importer pattern.
/// </summary>
internal static List<string> SplitCsv(string line, char separator)
{
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 == '"')
{
if (i + 1 < line.Length && line[i + 1] == '"')
{
sb.Append('"');
i++;
}
else
{
inQuotes = false;
}
}
else
{
sb.Append(c);
}
}
else
{
if (c == '"') inQuotes = true;
else if (c == separator)
{
fields.Add(sb.ToString());
sb.Clear();
}
else
{
sb.Append(c);
}
}
}
fields.Add(sb.ToString());
return fields;
}
}

View File

@@ -19,6 +19,10 @@
<ItemGroup>
<PackageReference Include="S7netplus" Version="0.20.0"/>
<!-- PR-S7-D1 / #299 — TiaCsvImporter + AwlImporter log skipped/malformed 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>