@@ -2,6 +2,7 @@ using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using S7.Net;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
@@ -67,6 +68,14 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — post-fan-out tag list, in declaration order. Mirrors
|
||||
/// <see cref="_tagsByName"/> as a list so <see cref="DiscoverAsync"/> can produce a
|
||||
/// stable browse-tree ordering. Always preserves the original declaration order;
|
||||
/// UDT tags are replaced in-place by their fanned-out leaf children.
|
||||
/// </summary>
|
||||
private readonly List<S7TagDefinition> _effectiveTags = new();
|
||||
|
||||
private readonly S7DriverOptions _options = options;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
@@ -141,8 +150,39 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
// story this lets the Admin UI's "Save" round-trip stay sub-second on bad input.
|
||||
_tagsByName.Clear();
|
||||
_parsedByName.Clear();
|
||||
_effectiveTags.Clear();
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
// PR-S7-D2 — UDT-typed tags are fanned out into N scalar leaf member tags
|
||||
// before any address parsing happens against the parent. Reads / writes /
|
||||
// subscribes never see the parent UDT tag; only its scalar children.
|
||||
if (!string.IsNullOrWhiteSpace(t.UdtName))
|
||||
{
|
||||
if (t.ElementCount is int udtElems && udtElems > 1)
|
||||
throw new FormatException(
|
||||
$"S7 tag '{t.Name}' is UDT-typed (UdtName='{t.UdtName}') and ElementCount > 1; " +
|
||||
"array-of-UDT must be expressed via array members inside the UDT layout, " +
|
||||
"not at the parent-tag level");
|
||||
|
||||
// Parse the parent-tag base address once so the fan-out can compute
|
||||
// member byte offsets relative to ByteOffset. Parser uses CpuType for
|
||||
// V-memory mapping on S7-200 / LOGO families.
|
||||
var parentParsed = S7AddressParser.Parse(t.Address, _options.CpuType);
|
||||
var leaves = S7UdtFanOut.Expand(t, _options.Udts, parentParsed);
|
||||
foreach (var leaf in leaves)
|
||||
{
|
||||
var leafParsed = S7AddressParser.Parse(leaf.Address, _options.CpuType);
|
||||
if (_tagsByName.ContainsKey(leaf.Name))
|
||||
throw new InvalidOperationException(
|
||||
$"S7 tag '{leaf.Name}' (fanned out from UDT '{t.UdtName}' on tag '{t.Name}') " +
|
||||
"collides with an existing tag name");
|
||||
_tagsByName[leaf.Name] = leaf;
|
||||
_parsedByName[leaf.Name] = leafParsed;
|
||||
_effectiveTags.Add(leaf);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pass CpuType so V-memory addresses (S7-200 / S7-200 Smart / LOGO!) resolve
|
||||
// against the device's family-specific DB mapping.
|
||||
var parsed = S7AddressParser.Parse(t.Address, _options.CpuType); // throws FormatException
|
||||
@@ -161,6 +201,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
}
|
||||
_tagsByName[t.Name] = t;
|
||||
_parsedByName[t.Name] = parsed;
|
||||
_effectiveTags.Add(t);
|
||||
}
|
||||
|
||||
var plc = BuildPlc();
|
||||
@@ -244,6 +285,9 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
// Reset the snapshot so a post-shutdown diagnostics read doesn't display a stale
|
||||
// PDU size from the previous connection. Reinit will repopulate after OpenAsync.
|
||||
_negotiatedPduSize = 0;
|
||||
// PR-S7-D2 — drop the post-fan-out tag list so a Reinit can rebuild it cleanly
|
||||
// without the previous run's UDT leaves leaking into the new tag map.
|
||||
_effectiveTags.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -1019,7 +1063,11 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var folder = builder.Folder("S7", "S7");
|
||||
foreach (var t in _options.Tags)
|
||||
// PR-S7-D2 — iterate the post-fan-out effective tag list so UDT leaves surface as
|
||||
// browseable variables and the original parent UDT tag (which is no longer in the
|
||||
// read/write pipeline) never appears in the address space.
|
||||
var sourceTags = _effectiveTags.Count > 0 ? (IEnumerable<S7TagDefinition>)_effectiveTags : _options.Tags;
|
||||
foreach (var t in sourceTags)
|
||||
{
|
||||
var isArr = t.ElementCount is int ec && ec > 1;
|
||||
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
||||
|
||||
@@ -86,6 +86,11 @@ public static class S7DriverFactoryExtensions
|
||||
LocalTsap = dto.LocalTsap,
|
||||
RemoteTsap = dto.RemoteTsap,
|
||||
ScanGroupIntervals = scanGroupMap,
|
||||
// PR-S7-D2 — UDT layout declarations referenced by tags whose UdtName is set.
|
||||
// Empty list when the config doesn't declare any UDTs (the typical scalar-only case).
|
||||
Udts = dto.Udts is { Count: > 0 }
|
||||
? [.. dto.Udts.Select(u => BuildUdt(u, driverInstanceId))]
|
||||
: [],
|
||||
};
|
||||
|
||||
return new S7Driver(options, driverInstanceId);
|
||||
@@ -95,16 +100,49 @@ public static class S7DriverFactoryExtensions
|
||||
new(
|
||||
Name: t.Name ?? throw new InvalidOperationException(
|
||||
$"S7 config for '{driverInstanceId}' has a tag missing Name"),
|
||||
// PR-S7-D2 — UDT-typed tags use the UDT name as their type rather than a primitive
|
||||
// S7 data type; Address is still required (the parent base address that the
|
||||
// fan-out adds member offsets to). Address may legitimately be DBn.DBX0.0 for an
|
||||
// entire-DB UDT pointer; the parser accepts that and the fan-out walks from there.
|
||||
Address: t.Address ?? throw new InvalidOperationException(
|
||||
$"S7 tag '{t.Name}' in '{driverInstanceId}' missing Address"),
|
||||
DataType: ParseEnum<S7DataType>(t.DataType, driverInstanceId, "DataType",
|
||||
tagName: t.Name),
|
||||
DataType: string.IsNullOrWhiteSpace(t.UdtName)
|
||||
? ParseEnum<S7DataType>(t.DataType, driverInstanceId, "DataType", tagName: t.Name)
|
||||
: ParseEnum<S7DataType>(t.DataType, driverInstanceId, "DataType", tagName: t.Name,
|
||||
fallback: S7DataType.Byte),
|
||||
Writable: t.Writable ?? true,
|
||||
StringLength: t.StringLength ?? 254,
|
||||
WriteIdempotent: t.WriteIdempotent ?? false,
|
||||
ScanGroup: string.IsNullOrWhiteSpace(t.ScanGroup) ? null : t.ScanGroup,
|
||||
DeadbandAbsolute: t.DeadbandAbsolute,
|
||||
DeadbandPercent: t.DeadbandPercent);
|
||||
DeadbandPercent: t.DeadbandPercent,
|
||||
UdtName: string.IsNullOrWhiteSpace(t.UdtName) ? null : t.UdtName);
|
||||
|
||||
private static S7UdtDefinition BuildUdt(S7UdtDto u, string driverInstanceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(u.Name))
|
||||
throw new InvalidOperationException(
|
||||
$"S7 config for '{driverInstanceId}' has a UDT entry missing Name");
|
||||
var members = (u.Members ?? new List<S7UdtMemberDto>()).Select(m => BuildUdtMember(m, u.Name!, driverInstanceId)).ToList();
|
||||
return new S7UdtDefinition(u.Name!, members, u.SizeBytes ?? 0);
|
||||
}
|
||||
|
||||
private static S7UdtMember BuildUdtMember(S7UdtMemberDto m, string udtName, string driverInstanceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(m.Name))
|
||||
throw new InvalidOperationException(
|
||||
$"S7 UDT '{udtName}' in '{driverInstanceId}' has a member missing Name");
|
||||
var dataType = string.IsNullOrWhiteSpace(m.UdtName)
|
||||
? ParseEnum<S7DataType>(m.DataType, driverInstanceId, $"UDT '{udtName}' member '{m.Name}' DataType")
|
||||
: ParseEnum<S7DataType>(m.DataType, driverInstanceId, $"UDT '{udtName}' member '{m.Name}' DataType",
|
||||
fallback: S7DataType.Byte);
|
||||
return new S7UdtMember(
|
||||
Name: m.Name!,
|
||||
Offset: m.Offset ?? 0,
|
||||
DataType: dataType,
|
||||
ArrayDim: m.ArrayDim,
|
||||
UdtName: string.IsNullOrWhiteSpace(m.UdtName) ? null : m.UdtName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D1 / #299 — append TIA Portal "Show all tags" CSV rows to
|
||||
@@ -225,6 +263,7 @@ public static class S7DriverFactoryExtensions
|
||||
LocalTsap = options.LocalTsap,
|
||||
RemoteTsap = options.RemoteTsap,
|
||||
ScanGroupIntervals = options.ScanGroupIntervals,
|
||||
Udts = options.Udts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -285,6 +324,14 @@ public static class S7DriverFactoryExtensions
|
||||
/// <c>docs/v2/s7.md</c> "Per-tag scan groups" section.
|
||||
/// </summary>
|
||||
public Dictionary<string, int>? ScanGroupIntervalsMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — UDT / STRUCT layout declarations. Tags whose
|
||||
/// <see cref="S7TagDto.UdtName"/> matches a UDT here get fanned out into
|
||||
/// scalar leaf member tags at <c>S7Driver.InitializeAsync</c> time.
|
||||
/// See <c>docs/v2/s7.md</c> "UDT / STRUCT support" section.
|
||||
/// </summary>
|
||||
public List<S7UdtDto>? Udts { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class S7TagDto
|
||||
@@ -320,6 +367,39 @@ public static class S7DriverFactoryExtensions
|
||||
/// set the filters are OR'd — publish if EITHER threshold triggers.
|
||||
/// </summary>
|
||||
public double? DeadbandPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — when set, the tag is a UDT / STRUCT-typed pointer. The driver
|
||||
/// looks up the named UDT in <see cref="S7DriverConfigDto.Udts"/> and fans the
|
||||
/// tag out into scalar leaf tags at <c>InitializeAsync</c> time. The primitive
|
||||
/// <see cref="DataType"/> field is ignored when <c>UdtName</c> is set; the
|
||||
/// leaves take their data types from the UDT member declarations.
|
||||
/// </summary>
|
||||
public string? UdtName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — JSON wire form for <see cref="S7UdtDefinition"/>. See
|
||||
/// <c>docs/v2/s7.md</c> "UDT / STRUCT support" for the round-trip example.
|
||||
/// </summary>
|
||||
internal sealed class S7UdtDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public List<S7UdtMemberDto>? Members { get; init; }
|
||||
public int? SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — JSON wire form for <see cref="S7UdtMember"/>. <c>UdtName</c> is set
|
||||
/// for nested-UDT members; <c>DataType</c> is set for primitive members.
|
||||
/// </summary>
|
||||
internal sealed class S7UdtMemberDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public int? Offset { get; init; }
|
||||
public string? DataType { get; init; }
|
||||
public int? ArrayDim { get; init; }
|
||||
public string? UdtName { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class S7ProbeDto
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
using S7NetCpuType = global::S7.Net.CpuType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
@@ -139,6 +140,17 @@ public sealed class S7DriverOptions
|
||||
/// batch any more, because the slow batch's <c>Task.Delay</c> isn't holding the gate.
|
||||
/// </remarks>
|
||||
public IReadOnlyDictionary<string, TimeSpan>? ScanGroupIntervals { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — UDT / STRUCT layout declarations referenced by tags whose
|
||||
/// <see cref="S7TagDefinition.UdtName"/> is set. At <see cref="S7Driver.InitializeAsync"/>
|
||||
/// time the driver fans every UDT-typed tag into N scalar leaf-member tags whose
|
||||
/// addresses equal <c>parent.Address + member.Offset</c>; reads / writes / subscribes
|
||||
/// never see the parent UDT tag, so the rest of the pipeline stays scalar-only.
|
||||
/// See <c>docs/v2/s7.md</c> "UDT / STRUCT support" section for the full semantics
|
||||
/// including the 4-level nesting cap and the Optimized-DB prerequisite.
|
||||
/// </summary>
|
||||
public IReadOnlyList<S7UdtDefinition> Udts { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -285,6 +297,16 @@ public sealed class S7ProbeOptions
|
||||
/// When both deadbands are set the filters are OR'd — the value publishes if EITHER
|
||||
/// threshold says publish (Kepware-style semantics). See <c>docs/v2/s7.md</c>.
|
||||
/// </param>
|
||||
/// <param name="UdtName">
|
||||
/// PR-S7-D2 — when set, this tag is a UDT / STRUCT-typed pointer. The driver looks up
|
||||
/// the named UDT in <see cref="S7DriverOptions.Udts"/> and fans the tag out into N scalar
|
||||
/// member tags at <see cref="S7Driver.InitializeAsync"/> time. <see cref="DataType"/> is
|
||||
/// ignored when <see cref="UdtName"/> is set (the parent UDT tag never reaches the
|
||||
/// read/write code path; only its scalar leaves do). Mutually exclusive with
|
||||
/// <see cref="ElementCount"/> > 1 — array-of-UDT is supported via array members
|
||||
/// inside the UDT layout, not at the parent-tag level. See <c>docs/v2/s7.md</c>
|
||||
/// "UDT / STRUCT support" section.
|
||||
/// </param>
|
||||
public sealed record S7TagDefinition(
|
||||
string Name,
|
||||
string Address,
|
||||
@@ -295,7 +317,8 @@ public sealed record S7TagDefinition(
|
||||
int? ElementCount = null,
|
||||
string? ScanGroup = null,
|
||||
double? DeadbandAbsolute = null,
|
||||
double? DeadbandPercent = null);
|
||||
double? DeadbandPercent = null,
|
||||
string? UdtName = null);
|
||||
|
||||
public enum S7DataType
|
||||
{
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — one named member of an <see cref="S7UdtDefinition"/>. Mirrors the
|
||||
/// STEP 7 / TIA Portal UDT layout: each member sits at a fixed byte offset relative
|
||||
/// to the UDT's base, has a primitive S7 type (or another UDT for nested STRUCTs),
|
||||
/// and may be a 1-D array.
|
||||
/// </summary>
|
||||
/// <param name="Name">Member name; concatenated with the parent tag's name via dot-separator at fan-out.</param>
|
||||
/// <param name="Offset">Byte offset within the parent UDT. Must be ascending and non-overlapping across the member list.</param>
|
||||
/// <param name="DataType">Primitive S7 type. Ignored when <paramref name="UdtName"/> is set (nested-UDT case).</param>
|
||||
/// <param name="ArrayDim">Optional 1-D array length. <c>null</c> / <c>1</c> = scalar; <c>> 1</c> emits indexed sub-tags <c>Member[0]</c>, <c>Member[1]</c>, ...</param>
|
||||
/// <param name="UdtName">When set, this member is itself a UDT — the fan-out recurses into the named UDT's layout. Mutually exclusive with the primitive interpretation of <paramref name="DataType"/>.</param>
|
||||
public sealed record S7UdtMember(
|
||||
string Name,
|
||||
int Offset,
|
||||
S7DataType DataType,
|
||||
int? ArrayDim = null,
|
||||
string? UdtName = null);
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — declarative description of a Siemens UDT (User-Defined Type) /
|
||||
/// STRUCT layout. Tags whose <see cref="S7TagDefinition.UdtName"/> matches
|
||||
/// <see cref="Name"/> get fanned-out into one scalar leaf tag per recursive
|
||||
/// member at <see cref="S7Driver.InitializeAsync"/> time, so the rest of the
|
||||
/// read/write/subscribe pipeline never has to know about UDTs.
|
||||
/// </summary>
|
||||
/// <param name="Name">UDT name. Case-insensitively matched against <see cref="S7TagDefinition.UdtName"/> and against nested members' <see cref="S7UdtMember.UdtName"/>.</param>
|
||||
/// <param name="Members">Ordered member list. Members must have ascending non-overlapping offsets; offsets that re-use bytes are rejected at fan-out time.</param>
|
||||
/// <param name="SizeBytes">Total UDT byte size, used as a sanity bound for the last member's offset + width.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Optimized block access</b>: TIA Portal can mark a DB or UDT as
|
||||
/// "Optimized block access" which lets the runtime reorder members for
|
||||
/// memory alignment. The static-offset model used here REQUIRES "Optimized
|
||||
/// block access" turned OFF on the parent DB; otherwise the declared
|
||||
/// offsets won't match the runtime layout. Same constraint applies to
|
||||
/// general absolute-offset DB addressing (see <c>docs/v2/s7.md</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nesting depth</b>: nested UDT-of-UDT is supported up to 4 levels.
|
||||
/// The fan-out throws <see cref="InvalidOperationException"/> on the 5th
|
||||
/// level — picked as a generous-but-still-bounded ceiling that catches
|
||||
/// pathological / accidentally-recursive declarations early.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record S7UdtDefinition(
|
||||
string Name,
|
||||
IReadOnlyList<S7UdtMember> Members,
|
||||
int SizeBytes);
|
||||
305
src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/S7UdtFanOut.cs
Normal file
305
src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/S7UdtFanOut.cs
Normal file
@@ -0,0 +1,305 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — UDT / STRUCT fan-out helper. Walks a parent <see cref="S7TagDefinition"/>
|
||||
/// whose <see cref="S7TagDefinition.UdtName"/> is set, looks up the matching
|
||||
/// <see cref="S7UdtDefinition"/>, recursively flattens its member tree into N scalar
|
||||
/// leaf tags, and returns the produced list so the driver's tag map can keep working
|
||||
/// in scalar-only mode for read / write / subscribe.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The fan-out happens once at <see cref="S7Driver.InitializeAsync"/> and the
|
||||
/// result REPLACES the parent UDT tag in the driver's tag map. Reads / writes /
|
||||
/// subscribes only ever target scalar leaves; the rest of the driver pipeline
|
||||
/// (S7AddressParser, S7BlockCoalescingPlanner, S7ReadPacker) remains UDT-unaware.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Naming is dot-separated: <c>ParentTag.Member.SubMember</c>. Array members
|
||||
/// emit indexed children (<c>ParentTag.Sensors[0]</c>, <c>ParentTag.Sensors[1]</c>, ...).
|
||||
/// Per-member addresses are constructed by adding the member byte offset (and the
|
||||
/// array-element index × element-bytes for array members) to the parent's parsed
|
||||
/// <see cref="S7ParsedAddress.ByteOffset"/> and re-rendering the address string in
|
||||
/// a width-suffix form the standard <see cref="S7AddressParser"/> will re-parse
|
||||
/// without modification.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class S7UdtFanOut
|
||||
{
|
||||
/// <summary>
|
||||
/// Hard ceiling on UDT-of-UDT recursion depth. Picked as "generous but bounded"
|
||||
/// so a real 3-level analytics struct still fans out, but an accidentally-recursive
|
||||
/// declaration ("MyUdt contains MyUdt") fails fast at Init instead of stack-overflowing.
|
||||
/// </summary>
|
||||
public const int MaxNestingDepth = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Fan a parent UDT-typed tag into N scalar member tags, emitting one scalar
|
||||
/// <see cref="S7TagDefinition"/> per leaf member.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<S7TagDefinition> Expand(
|
||||
S7TagDefinition parent,
|
||||
IReadOnlyList<S7UdtDefinition> udts,
|
||||
S7ParsedAddress parentAddress)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parent.UdtName))
|
||||
throw new InvalidOperationException(
|
||||
$"S7UdtFanOut.Expand called for tag '{parent.Name}' which has no UdtName");
|
||||
|
||||
var udtIndex = BuildUdtIndex(udts);
|
||||
var emitted = new List<S7TagDefinition>();
|
||||
ExpandRecursive(
|
||||
tagPath: parent.Name,
|
||||
udtName: parent.UdtName!,
|
||||
baseByteOffset: parentAddress.ByteOffset,
|
||||
parsedTemplate: parentAddress,
|
||||
parent: parent,
|
||||
udtIndex: udtIndex,
|
||||
depth: 1,
|
||||
emitted: emitted);
|
||||
return emitted;
|
||||
}
|
||||
|
||||
private static Dictionary<string, S7UdtDefinition> BuildUdtIndex(IReadOnlyList<S7UdtDefinition> udts)
|
||||
{
|
||||
var dict = new Dictionary<string, S7UdtDefinition>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var udt in udts)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(udt.Name))
|
||||
continue;
|
||||
dict[udt.Name] = udt;
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
private static void ExpandRecursive(
|
||||
string tagPath,
|
||||
string udtName,
|
||||
int baseByteOffset,
|
||||
S7ParsedAddress parsedTemplate,
|
||||
S7TagDefinition parent,
|
||||
Dictionary<string, S7UdtDefinition> udtIndex,
|
||||
int depth,
|
||||
List<S7TagDefinition> emitted)
|
||||
{
|
||||
if (depth > MaxNestingDepth)
|
||||
throw new InvalidOperationException(
|
||||
$"UDT nesting depth exceeds {MaxNestingDepth} levels at tag '{tagPath}' (UDT '{udtName}')");
|
||||
|
||||
if (!udtIndex.TryGetValue(udtName, out var udt))
|
||||
throw new InvalidOperationException(
|
||||
$"UDT '{udtName}' referenced by tag '{tagPath}' but not declared in S7DriverOptions.Udts");
|
||||
|
||||
ValidateMemberLayout(udt, tagPath);
|
||||
|
||||
foreach (var member in udt.Members)
|
||||
{
|
||||
var memberOffset = baseByteOffset + member.Offset;
|
||||
if (!string.IsNullOrWhiteSpace(member.UdtName))
|
||||
{
|
||||
// Nested UDT — recurse. Arrays of UDT walk one nesting level per index slot
|
||||
// sharing the same depth budget; the depth cap counts UDT layers, not array
|
||||
// expansions, because array indices don't grow the call-graph fan-out shape.
|
||||
if (member.ArrayDim is int udtArrLen && udtArrLen > 1)
|
||||
{
|
||||
var nestedUdt = LookupOrThrow(udtIndex, member.UdtName!, $"{tagPath}.{member.Name}");
|
||||
var stride = nestedUdt.SizeBytes;
|
||||
if (stride <= 0)
|
||||
throw new InvalidOperationException(
|
||||
$"UDT '{member.UdtName}' has SizeBytes <= 0; cannot stride array-of-UDT for member '{member.Name}'");
|
||||
for (var i = 0; i < udtArrLen; i++)
|
||||
{
|
||||
ExpandRecursive(
|
||||
tagPath: $"{tagPath}.{member.Name}[{i}]",
|
||||
udtName: member.UdtName!,
|
||||
baseByteOffset: memberOffset + i * stride,
|
||||
parsedTemplate: parsedTemplate,
|
||||
parent: parent,
|
||||
udtIndex: udtIndex,
|
||||
depth: depth + 1,
|
||||
emitted: emitted);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ExpandRecursive(
|
||||
tagPath: $"{tagPath}.{member.Name}",
|
||||
udtName: member.UdtName!,
|
||||
baseByteOffset: memberOffset,
|
||||
parsedTemplate: parsedTemplate,
|
||||
parent: parent,
|
||||
udtIndex: udtIndex,
|
||||
depth: depth + 1,
|
||||
emitted: emitted);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Primitive leaf — emit one scalar tag, or N tags for an array member.
|
||||
if (member.ArrayDim is int arrLen && arrLen > 1)
|
||||
{
|
||||
var elemBytes = PrimitiveElementBytes(member.DataType);
|
||||
for (var i = 0; i < arrLen; i++)
|
||||
{
|
||||
var elementOffset = memberOffset + i * elemBytes;
|
||||
var leafName = $"{tagPath}.{member.Name}[{i}]";
|
||||
emitted.Add(BuildLeafTag(leafName, elementOffset, member.DataType, parsedTemplate, parent));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var leafName = $"{tagPath}.{member.Name}";
|
||||
emitted.Add(BuildLeafTag(leafName, memberOffset, member.DataType, parsedTemplate, parent));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static S7UdtDefinition LookupOrThrow(
|
||||
Dictionary<string, S7UdtDefinition> index, string udtName, string referencedFrom)
|
||||
{
|
||||
if (!index.TryGetValue(udtName, out var udt))
|
||||
throw new InvalidOperationException(
|
||||
$"UDT '{udtName}' referenced by tag '{referencedFrom}' but not declared in S7DriverOptions.Udts");
|
||||
return udt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reject misordered or overlapping member offsets — each UDT must be sortable as a
|
||||
/// monotone non-overlapping sequence of byte ranges, and the last range must fit
|
||||
/// within <see cref="S7UdtDefinition.SizeBytes"/>.
|
||||
/// </summary>
|
||||
private static void ValidateMemberLayout(S7UdtDefinition udt, string tagPath)
|
||||
{
|
||||
var prevEnd = -1;
|
||||
var prevName = "<start>";
|
||||
for (var i = 0; i < udt.Members.Count; i++)
|
||||
{
|
||||
var m = udt.Members[i];
|
||||
if (m.Offset < prevEnd)
|
||||
throw new InvalidOperationException(
|
||||
$"UDT '{udt.Name}' (used by tag '{tagPath}') has overlapping or misordered " +
|
||||
$"member offsets: '{m.Name}' at {m.Offset} overlaps previous member '{prevName}' ending at {prevEnd}");
|
||||
|
||||
// Approximate member width — for nested UDT we don't have its size at this point,
|
||||
// but ascending-offset ordering still catches the typical mistake. Primitive widths
|
||||
// come from PrimitiveElementBytes; arrays multiply by ArrayDim.
|
||||
int width;
|
||||
if (!string.IsNullOrWhiteSpace(m.UdtName))
|
||||
width = 1; // sentinel — recursive expansion validates the nested layout itself.
|
||||
else
|
||||
width = PrimitiveElementBytes(m.DataType) * Math.Max(1, m.ArrayDim ?? 1);
|
||||
|
||||
prevEnd = m.Offset + width;
|
||||
prevName = m.Name;
|
||||
}
|
||||
|
||||
if (udt.SizeBytes > 0 && prevEnd > udt.SizeBytes)
|
||||
throw new InvalidOperationException(
|
||||
$"UDT '{udt.Name}' members extend past declared SizeBytes={udt.SizeBytes} " +
|
||||
$"(last member '{prevName}' ends at {prevEnd})");
|
||||
}
|
||||
|
||||
private static S7TagDefinition BuildLeafTag(
|
||||
string name,
|
||||
int byteOffset,
|
||||
S7DataType dataType,
|
||||
S7ParsedAddress parsedTemplate,
|
||||
S7TagDefinition parent)
|
||||
{
|
||||
var address = BuildAddressString(parsedTemplate, byteOffset, dataType);
|
||||
return new S7TagDefinition(
|
||||
Name: name,
|
||||
Address: address,
|
||||
DataType: dataType,
|
||||
Writable: parent.Writable,
|
||||
StringLength: parent.StringLength,
|
||||
WriteIdempotent: parent.WriteIdempotent,
|
||||
ElementCount: null,
|
||||
ScanGroup: parent.ScanGroup,
|
||||
DeadbandAbsolute: parent.DeadbandAbsolute,
|
||||
DeadbandPercent: parent.DeadbandPercent,
|
||||
UdtName: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render a synthetic S7 address string for a fanned-out leaf tag. Re-uses the parent
|
||||
/// tag's parsed address for the area / DB number; picks a width suffix matching the
|
||||
/// leaf <paramref name="dataType"/> so the standard parser will accept it on the
|
||||
/// return trip.
|
||||
/// </summary>
|
||||
public static string BuildAddressString(S7ParsedAddress parent, int byteOffset, S7DataType dataType)
|
||||
{
|
||||
var width = AddressWidthFor(dataType);
|
||||
switch (parent.Area)
|
||||
{
|
||||
case S7Area.DataBlock:
|
||||
return width switch
|
||||
{
|
||||
AddressWidth.Bit => $"DB{parent.DbNumber}.DBX{byteOffset}.0",
|
||||
AddressWidth.Byte => $"DB{parent.DbNumber}.DBB{byteOffset}",
|
||||
AddressWidth.Word => $"DB{parent.DbNumber}.DBW{byteOffset}",
|
||||
AddressWidth.DWord => $"DB{parent.DbNumber}.DBD{byteOffset}",
|
||||
AddressWidth.LWord => $"DB{parent.DbNumber}.DBLD{byteOffset}",
|
||||
_ => throw new InvalidOperationException($"Unhandled address width {width}"),
|
||||
};
|
||||
case S7Area.Memory:
|
||||
return RenderAreaPrefix("M", width, byteOffset);
|
||||
case S7Area.Input:
|
||||
return RenderAreaPrefix("I", width, byteOffset);
|
||||
case S7Area.Output:
|
||||
return RenderAreaPrefix("Q", width, byteOffset);
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"UDT fan-out only supports DataBlock / M / I / Q parents; got {parent.Area}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string RenderAreaPrefix(string prefix, AddressWidth width, int byteOffset) => width switch
|
||||
{
|
||||
AddressWidth.Bit => $"{prefix}{byteOffset}.0",
|
||||
AddressWidth.Byte => $"{prefix}B{byteOffset}",
|
||||
AddressWidth.Word => $"{prefix}W{byteOffset}",
|
||||
AddressWidth.DWord => $"{prefix}D{byteOffset}",
|
||||
AddressWidth.LWord => $"{prefix}LD{byteOffset}",
|
||||
_ => throw new InvalidOperationException($"Unhandled address width {width}"),
|
||||
};
|
||||
|
||||
private enum AddressWidth { Bit, Byte, Word, DWord, LWord }
|
||||
|
||||
private static AddressWidth AddressWidthFor(S7DataType t) => t switch
|
||||
{
|
||||
S7DataType.Bool => AddressWidth.Bit,
|
||||
S7DataType.Byte or S7DataType.Char => AddressWidth.Byte,
|
||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.WChar or S7DataType.Date or S7DataType.S5Time
|
||||
=> AddressWidth.Word,
|
||||
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32 or S7DataType.Time or S7DataType.TimeOfDay
|
||||
=> AddressWidth.DWord,
|
||||
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64
|
||||
=> AddressWidth.LWord,
|
||||
// Variable-width / structured leaves don't have a single fixed-width address shape;
|
||||
// rendered as a byte-prefixed address — caller's S7AddressParser will accept and
|
||||
// the codec layer handles the structured payload from byteOffset onward.
|
||||
S7DataType.String or S7DataType.WString or S7DataType.DateTime or S7DataType.Dtl or S7DataType.DateAndTime
|
||||
=> AddressWidth.Byte,
|
||||
_ => throw new InvalidOperationException($"AddressWidthFor: unhandled S7DataType {t}"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// On-wire byte width of a primitive UDT member used for array-element stride and
|
||||
/// overlap validation. Variable-width / structured types are rejected at fan-out
|
||||
/// time because their layout cannot be treated as a fixed array stride.
|
||||
/// </summary>
|
||||
public static int PrimitiveElementBytes(S7DataType t) => t switch
|
||||
{
|
||||
S7DataType.Bool or S7DataType.Byte or S7DataType.Char => 1,
|
||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.WChar or S7DataType.Date or S7DataType.S5Time => 2,
|
||||
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32 or S7DataType.Time or S7DataType.TimeOfDay => 4,
|
||||
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 or S7DataType.DateAndTime => 8,
|
||||
S7DataType.Dtl => 12,
|
||||
S7DataType.DateTime => 8,
|
||||
// Variable-width string types as UDT members would need explicit per-member length;
|
||||
// not yet supported. Fall back to "treat as 1-byte" for the prevEnd math but caller
|
||||
// gets a clear runtime error from the address parser for unsupported types.
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user