Auto: s7-d2 — UDT / STRUCT / nested-DB fan-out

Closes #300
This commit is contained in:
Joseph Doherty
2026-04-26 06:50:26 -04:00
parent 7e62a1158f
commit 5f8d84db43
13 changed files with 1139 additions and 16 deletions

View File

@@ -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(

View File

@@ -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

View File

@@ -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"/> &gt; 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
{

View File

@@ -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>&gt; 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);

View 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,
};
}