Auto: s7-d1 — TIA Portal CSV + STEP 7 Classic AWL symbol import
Closes #299
This commit is contained in:
@@ -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/<instance>/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}.";
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
434
src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/AwlImporter.cs
Normal file
434
src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/AwlImporter.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
514
src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/TiaCsvImporter.cs
Normal file
514
src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/TiaCsvImporter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user