@@ -0,0 +1,226 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D3 / #301 — recognise and resolve TIA Portal CSV rows that belong to a
|
||||
/// <b>multi-instance Function-Block instance DB</b>. Multi-instance FBs are addressed
|
||||
/// symbolically (<c>MyFB_Instance.MyParam</c>) inside the PLC program, but the runtime
|
||||
/// wire access still needs the absolute <c>DBn.DBW_offset</c>. TIA's "Show all tags"
|
||||
/// CSV export tags these rows with a different <c>DB type</c> column value
|
||||
/// (locale-variant: <c>Instance DB</c> / <c>Instance</c> / <c>Instance data block</c>)
|
||||
/// and — most of the time — already supplies the resolved absolute address in the
|
||||
/// <c>Logical address</c> column.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Two-phase model</b>:
|
||||
/// <list type="number">
|
||||
/// <item>
|
||||
/// <c>DB type</c> column == <c>Instance DB</c> (or locale variant) AND
|
||||
/// <c>Logical address</c> non-empty → accept verbatim, normalise the address
|
||||
/// the same way as a Global-DB row, count it under
|
||||
/// <see cref="S7ImportResult.InstanceDbCount"/>.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <c>DB type</c> column == <c>Instance DB</c> AND <c>Logical address</c>
|
||||
/// empty BUT a parent FB-interface declaration is reachable → compute
|
||||
/// <c>DBn.DBW(parentOffset + memberOffset)</c> from the interface table.
|
||||
/// The pure-compute fallback is rare in practice (TIA almost always emits the
|
||||
/// resolved address) but is required by the PR plan.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Re-import on FB-interface edit</b>: when the FB interface changes (member
|
||||
/// added, removed, or reordered in TIA), the instance-DB layout shifts. Operators
|
||||
/// must re-import the CSV after any such edit; absolute offsets that cached against
|
||||
/// the previous layout will silently point at the wrong member otherwise. See
|
||||
/// <c>docs/drivers/S7-TIA-Import.md</c> "Instance DBs / FB parameters" section.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class InstanceDbResolver
|
||||
{
|
||||
private readonly ILogger<InstanceDbResolver> _logger;
|
||||
|
||||
public InstanceDbResolver() : this(NullLogger<InstanceDbResolver>.Instance) { }
|
||||
|
||||
public InstanceDbResolver(ILogger<InstanceDbResolver> logger)
|
||||
{
|
||||
_logger = logger ?? NullLogger<InstanceDbResolver>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recognise an arbitrary <c>DB type</c> column value as identifying an
|
||||
/// instance-DB row. Accepts the canonical TIA en-US value <c>Instance DB</c>,
|
||||
/// the bare <c>Instance</c>, the German <c>Instanz-Datenbaustein</c> /
|
||||
/// <c>Instanz-DB</c>, and a few other common locale variants. The match is
|
||||
/// case-insensitive and tolerates surrounding whitespace + quotes.
|
||||
/// </summary>
|
||||
public static bool IsInstanceDbType(string? dbTypeColumn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dbTypeColumn)) return false;
|
||||
var v = dbTypeColumn.Trim().Trim('"').Trim().ToLowerInvariant();
|
||||
return v switch
|
||||
{
|
||||
"instance" => true,
|
||||
"instance db" => true,
|
||||
"instance-db" => true,
|
||||
"instance data block" => true,
|
||||
// German + dashed variants TIA emits when the project locale is DE.
|
||||
"instanz" => true,
|
||||
"instanz-db" => true,
|
||||
"instanz db" => true,
|
||||
"instanz-datenbaustein" => true,
|
||||
"instanz datenbaustein" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recognise the <c>DB type</c> column value as identifying a Global / standard
|
||||
/// data block (the default that PR-S7-D1 already handles). Useful for explicit
|
||||
/// downstream branching — anything that is neither Global nor Instance is logged
|
||||
/// and treated as Global so the existing pipeline keeps working.
|
||||
/// </summary>
|
||||
public static bool IsGlobalDbType(string? dbTypeColumn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dbTypeColumn)) return false;
|
||||
var v = dbTypeColumn.Trim().Trim('"').Trim().ToLowerInvariant();
|
||||
return v switch
|
||||
{
|
||||
"global" or "global db" or "global-db" or "global data block" => true,
|
||||
"globaler datenbaustein" or "globaler-datenbaustein" or "global-datenbaustein" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve an instance-DB row into an <see cref="S7TagDefinition"/>. Accepts both
|
||||
/// the canonical "address-already-resolved" form (the common case) and the
|
||||
/// "compute from interface offsets" form for exports that ship offsets only.
|
||||
/// </summary>
|
||||
/// <param name="row">Raw row state — Name, resolved address (if any), declared data type, parent FB info.</param>
|
||||
/// <param name="resolved">On success, the materialised tag definition; address is normalised (no leading <c>%</c>) the same way <see cref="TiaCsvImporter.NormaliseAddress"/> does it.</param>
|
||||
/// <returns><c>true</c> if the row was recognised AND resolved; <c>false</c> when neither a resolved address nor sufficient interface info was available.</returns>
|
||||
public bool TryResolve(InstanceDbRow row, out S7TagDefinition? resolved)
|
||||
{
|
||||
resolved = null;
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
// Path 1 — address already resolved by TIA (the common case; the export ships
|
||||
// %DB7.DBW0 verbatim for a multi-instance FB member).
|
||||
if (!string.IsNullOrWhiteSpace(row.LogicalAddress))
|
||||
{
|
||||
var normalised = TiaCsvImporter.NormaliseAddress(row.LogicalAddress, row.DeLocale);
|
||||
if (!S7AddressParser.TryParse(normalised, out _))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"InstanceDbResolver: row '{Name}' has unparseable address '{Address}' (DB type='{DbType}').",
|
||||
row.Name, normalised, row.DbType);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TiaCsvImporter.TryResolveDataType(row.DataType ?? string.Empty, normalised, out var s7Type))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"InstanceDbResolver: row '{Name}' has unrecognised Data type '{DataType}' for address '{Address}'.",
|
||||
row.Name, row.DataType, normalised);
|
||||
return false;
|
||||
}
|
||||
|
||||
resolved = new S7TagDefinition(
|
||||
Name: row.Name,
|
||||
Address: normalised,
|
||||
DataType: s7Type,
|
||||
Writable: true,
|
||||
StringLength: row.StringLength ?? 254);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Path 2 — compute the absolute address from the FB interface declaration.
|
||||
// Required only when the TIA export ships interface offsets without resolved
|
||||
// addresses; rare in practice but in the PR plan.
|
||||
if (row.ParentDbNumber is int dbNumber && row.MemberOffset is int memberOffset)
|
||||
{
|
||||
if (!TiaCsvImporter.TryResolveDataType(row.DataType ?? string.Empty, string.Empty, out var s7Type))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"InstanceDbResolver: row '{Name}' has unrecognised Data type '{DataType}' (interface-resolved path).",
|
||||
row.Name, row.DataType);
|
||||
return false;
|
||||
}
|
||||
|
||||
var sizeLetter = SizeLetterFor(s7Type);
|
||||
var absOffset = (row.ParentBaseOffset ?? 0) + memberOffset;
|
||||
var address = $"DB{dbNumber.ToString(CultureInfo.InvariantCulture)}.DB{sizeLetter}{absOffset.ToString(CultureInfo.InvariantCulture)}";
|
||||
|
||||
if (s7Type == S7DataType.Bool && row.MemberBitOffset is int bitOffset)
|
||||
{
|
||||
address = $"DB{dbNumber.ToString(CultureInfo.InvariantCulture)}.DBX{absOffset.ToString(CultureInfo.InvariantCulture)}.{bitOffset.ToString(CultureInfo.InvariantCulture)}";
|
||||
}
|
||||
|
||||
if (!S7AddressParser.TryParse(address, out _))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"InstanceDbResolver: row '{Name}' computed unparseable address '{Address}'.",
|
||||
row.Name, address);
|
||||
return false;
|
||||
}
|
||||
|
||||
resolved = new S7TagDefinition(
|
||||
Name: row.Name,
|
||||
Address: address,
|
||||
DataType: s7Type,
|
||||
Writable: true,
|
||||
StringLength: row.StringLength ?? 254);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"InstanceDbResolver: row '{Name}' has no resolved address and insufficient interface info to compute one (DB type='{DbType}').",
|
||||
row.Name, row.DbType);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static char SizeLetterFor(S7DataType t) => t switch
|
||||
{
|
||||
S7DataType.Bool => 'X',
|
||||
S7DataType.Byte or S7DataType.Char => 'B',
|
||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.WChar or S7DataType.Date => 'W',
|
||||
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32
|
||||
or S7DataType.Time or S7DataType.TimeOfDay => 'D',
|
||||
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 'D', // S7-1500 LWord lives in DBD-stride pairs
|
||||
// STRING / WSTRING / DTL / DT / S5Time map to byte access — the codec slices client-side.
|
||||
_ => 'B',
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D3 — input row to <see cref="InstanceDbResolver.TryResolve"/>. Carries
|
||||
/// everything the resolver needs to either accept a TIA-resolved address verbatim
|
||||
/// or compute one from FB-interface offsets.
|
||||
/// </summary>
|
||||
/// <param name="Name">Tag name; same semantics as <see cref="S7TagDefinition.Name"/>.</param>
|
||||
/// <param name="DbType">Raw <c>DB type</c> column value as it appeared in the CSV — used in diagnostic logs only.</param>
|
||||
/// <param name="LogicalAddress">TIA <c>Logical address</c> column. May be empty when the export ships only interface offsets.</param>
|
||||
/// <param name="DataType">TIA <c>Data type</c> column (primitive type name).</param>
|
||||
/// <param name="DeLocale">DE-locale flag forwarded from the importer so address normalisation rewrites comma-decimals.</param>
|
||||
/// <param name="StringLength">Optional <c>Length</c> column for STRING tags; null = default 254.</param>
|
||||
/// <param name="ParentDbNumber">For the interface-offset path: the DB number the FB instance lives in.</param>
|
||||
/// <param name="ParentBaseOffset">For the interface-offset path: the FB instance's base byte offset within the DB. Defaults to 0 when null.</param>
|
||||
/// <param name="MemberOffset">For the interface-offset path: the member's byte offset within the FB interface.</param>
|
||||
/// <param name="MemberBitOffset">For the interface-offset path: the bit offset for BOOL members; null for non-BOOL members.</param>
|
||||
public sealed record InstanceDbRow(
|
||||
string Name,
|
||||
string? DbType,
|
||||
string? LogicalAddress,
|
||||
string? DataType,
|
||||
bool DeLocale = false,
|
||||
int? StringLength = null,
|
||||
int? ParentDbNumber = null,
|
||||
int? ParentBaseOffset = null,
|
||||
int? MemberOffset = null,
|
||||
int? MemberBitOffset = null);
|
||||
@@ -3,18 +3,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.
|
||||
/// <see cref="ParsedCount"/>, <see cref="SkippedCount"/>, <see cref="ErrorCount"/>,
|
||||
/// <see cref="UdtPlaceholderCount"/>, and <see cref="InstanceDbCount"/> give the operator
|
||||
/// a single line of telemetry ("imported 142 / skipped 3 / errored 0 / udt 5 / instance-db 9")
|
||||
/// 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.
|
||||
/// <see cref="ParsedCount"/> includes UDT placeholders and instance-DB rows —
|
||||
/// both count as imported tags (they materialise as <see cref="S7TagDefinition"/>
|
||||
/// rows the driver can list in the Admin UI). <see cref="UdtPlaceholderCount"/>
|
||||
/// and <see cref="InstanceDbCount"/> are sub-counts operators can compare against
|
||||
/// <see cref="ParsedCount"/> to spot how the import breaks down.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="InstanceDbCount"/> (PR-S7-D3 / #301) tracks rows whose <c>DB type</c>
|
||||
/// column identified them as belonging to a multi-instance FB-instance DB. They
|
||||
/// import as fully-functional tags — the count is informational so an operator
|
||||
/// knows how many of the imported rows came from FB-instance DBs (and therefore
|
||||
/// need a re-import after any FB-interface edit on the PLC side).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record S7ImportResult(
|
||||
@@ -22,4 +29,5 @@ public sealed record S7ImportResult(
|
||||
int ParsedCount,
|
||||
int SkippedCount,
|
||||
int ErrorCount,
|
||||
int UdtPlaceholderCount);
|
||||
int UdtPlaceholderCount,
|
||||
int InstanceDbCount = 0);
|
||||
|
||||
@@ -65,6 +65,9 @@ public sealed class TiaCsvImporter : IS7SymbolImporter
|
||||
var skipped = 0;
|
||||
var errors = 0;
|
||||
var udtPlaceholders = 0;
|
||||
var instanceDbCount = 0;
|
||||
|
||||
var resolver = new InstanceDbResolver();
|
||||
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8,
|
||||
detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
|
||||
@@ -75,6 +78,7 @@ public sealed class TiaCsvImporter : IS7SymbolImporter
|
||||
int? commentIdx = null;
|
||||
int? hmiAccessibleIdx = null;
|
||||
int? lengthIdx = null;
|
||||
int? dbTypeIdx = null;
|
||||
var headerSeen = false;
|
||||
var deLocale = false;
|
||||
var lineNumber = 0;
|
||||
@@ -125,6 +129,14 @@ public sealed class TiaCsvImporter : IS7SymbolImporter
|
||||
case "hmi accessible":
|
||||
case "hmi-accessible": hmiAccessibleIdx = i; break;
|
||||
case "length": lengthIdx = i; break;
|
||||
// PR-S7-D3 — TIA Portal exports include a "DB type" column
|
||||
// that distinguishes Global DBs (already handled by D1) from
|
||||
// multi-instance FB instance DBs (the new bucket). DE locale
|
||||
// emits "Datenbaustein-Typ"; both header forms are accepted.
|
||||
case "db type":
|
||||
case "db-type":
|
||||
case "datenbaustein-typ":
|
||||
case "datenbausteintyp": dbTypeIdx = i; break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +182,7 @@ public sealed class TiaCsvImporter : IS7SymbolImporter
|
||||
? ParseBoolColumn(SafeField(fields, hmiAccessibleIdx.Value), defaultValue: true)
|
||||
: true;
|
||||
var lengthStr = lengthIdx.HasValue ? SafeField(fields, lengthIdx.Value).Trim().Trim('"') : null;
|
||||
var dbTypeStr = dbTypeIdx.HasValue ? SafeField(fields, dbTypeIdx.Value).Trim().Trim('"') : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(address))
|
||||
{
|
||||
@@ -190,6 +203,49 @@ public sealed class TiaCsvImporter : IS7SymbolImporter
|
||||
// to '.' so the rest of the pipeline sees a single canonical address shape.
|
||||
var normalised = NormaliseAddress(address, deLocale);
|
||||
|
||||
// PR-S7-D3 — recognise instance-DB rows (multi-instance FB members) BEFORE
|
||||
// the UDT detection runs. TIA's CSV typically already ships the resolved
|
||||
// absolute address (%DB7.DBW0) so the data type column is a primitive — but
|
||||
// the row still wants to land in the InstanceDbCount sub-counter so the
|
||||
// operator knows how much of the import depends on the FB-interface layout.
|
||||
// (Re-import is required after any FB-interface edit; see docs.)
|
||||
if (InstanceDbResolver.IsInstanceDbType(dbTypeStr))
|
||||
{
|
||||
var idbRow = new InstanceDbRow(
|
||||
Name: name,
|
||||
DbType: dbTypeStr,
|
||||
LogicalAddress: address, // raw address — resolver normalises internally
|
||||
DataType: dataTypeStr,
|
||||
DeLocale: deLocale,
|
||||
StringLength: int.TryParse(lengthStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var idbLen) && idbLen > 0
|
||||
? idbLen
|
||||
: null);
|
||||
|
||||
if (resolver.TryResolve(idbRow, out var idbTag) && idbTag is not null)
|
||||
{
|
||||
tags.Add(idbTag);
|
||||
instanceDbCount++;
|
||||
parsed++;
|
||||
_logger.LogInformation(
|
||||
"TIA CSV row at line {LineNumber} accepted as instance-DB member (Name='{Name}', Address='{Address}').",
|
||||
lineNumber, idbTag.Name, idbTag.Address);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolver rejected the row — fall through to the strict-error path
|
||||
// so the outer try/catch consistently increments errors / skips.
|
||||
if (!opts.IgnoreInvalid)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"TIA CSV row at line {lineNumber} flagged as instance-DB but failed to resolve (Name='{name}', address='{address}').");
|
||||
}
|
||||
errors++;
|
||||
_logger.LogWarning(
|
||||
"TIA CSV row at line {LineNumber} skipped — instance-DB row failed to resolve (Name='{Name}', address='{Address}').",
|
||||
lineNumber, name, address);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -271,10 +327,10 @@ public sealed class TiaCsvImporter : IS7SymbolImporter
|
||||
|
||||
if (!headerSeen)
|
||||
{
|
||||
return new S7ImportResult([], 0, skipped, errors, udtPlaceholders);
|
||||
return new S7ImportResult([], 0, skipped, errors, udtPlaceholders, instanceDbCount);
|
||||
}
|
||||
|
||||
return new S7ImportResult(tags, parsed, skipped, errors, udtPlaceholders);
|
||||
return new S7ImportResult(tags, parsed, skipped, errors, udtPlaceholders, instanceDbCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user