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(