Merge pull request '[s7] S7 — UDT / STRUCT / nested-DB handling' (#394) from auto/s7/PR-S7-D2 into auto/driver-gaps

This commit was merged in pull request #394.
This commit is contained in:
2026-04-26 06:53:12 -04:00
13 changed files with 1139 additions and 16 deletions

View File

@@ -57,12 +57,39 @@ into the S7 driver. Saves operators from hand-typing every `%MW0` /
UDT-typed symbols (TIA `Data type` = `"MyUdt"` quoted, or the literal `Struct`) UDT-typed symbols (TIA `Data type` = `"MyUdt"` quoted, or the literal `Struct`)
import as a **placeholder** — the resulting tag lands in the driver options so import as a **placeholder** — the resulting tag lands in the driver options so
it shows up in the Admin UI tag list, but its data type is forced to `Byte` it shows up in the Admin UI tag list, but its data type is forced to `Byte`
and the row is marked `Writable = false`. PR-S7-D2 will replace the placeholder and the row is marked `Writable = false`.
with proper UDT layout once the symbol table covers nested struct fields.
`S7ImportResult.UdtPlaceholderCount` tracks how many of the imported tags `S7ImportResult.UdtPlaceholderCount` tracks how many of the imported tags
landed in this bucket. landed in this bucket.
#### Cooperation with `Udts` declarations (PR-S7-D2 / #300)
PR-S7-D2 ships UDT fan-out via `S7DriverOptions.Udts` + `S7TagDefinition.UdtName`.
The importer and the `Udts` declaration cooperate as follows:
1. The importer emits a placeholder row for each UDT-typed symbol — same as
today (data type forced to `Byte`, `Writable = false`).
2. The operator hand-edits the placeholder row in the resulting JSON / options
object and:
- Sets `UdtName` to the UDT type name from the TIA "Data type" column
- Removes the `Writable: false` marker (UDT leaves inherit the parent's
writability)
3. The operator declares the matching `S7UdtDefinition` in
`S7DriverOptions.Udts` (member offsets come from the TIA UDT definition
in the project file — TIA's "Show all tags" CSV does not export struct
field offsets, hence the manual layout step).
4. At driver init, the fan-out replaces the placeholder with one scalar leaf
per UDT member.
The importer does NOT auto-populate `Udts` — UDT layouts live in the project
file, not the symbol-table CSV. A future enhancement may parse the SCL UDT
declaration alongside the CSV; for now the cooperation is "importer flags it,
operator declares the layout, driver fans out at init".
See [`docs/v2/s7.md` "UDT / STRUCT support"](../v2/s7.md#udt--struct-support)
for the full fan-out semantics, the 4-level nesting cap, and the
Optimized-block-access prerequisite.
## DE locale handling ## DE locale handling
TIA Portal honours the Windows display locale when writing CSV. A DE-locale TIA Portal honours the Windows display locale when writing CSV. A DE-locale
@@ -206,9 +233,11 @@ For a hand-managed importer instance (e.g. supplying a custom `ILogger`) call
object or de-duplicate themselves; a future schema rev may add a object or de-duplicate themselves; a future schema rev may add a
`replace=true` switch. `replace=true` switch.
- UDT placeholders surface in the Admin UI as non-writable Byte tags. PR-S7-D2 - UDT placeholders surface in the Admin UI as non-writable Byte tags. PR-S7-D2
will replace the placeholder rows with proper UDT layout (one tag per added the runtime UDT fan-out (`S7DriverOptions.Udts` + `S7TagDefinition.UdtName`)
primitive field); operators should not bind dependent client tags to — operators upgrade a placeholder row by setting `UdtName` and declaring the
placeholder rows because the addresses will be rewritten when D2 lands. matching `S7UdtDefinition`; see "Cooperation with `Udts` declarations" above.
Placeholder-only rows still work as a Byte view of the first byte but
can't browse / read their members until the layout is declared.
- Description metadata is dropped on the floor today — see the column - Description metadata is dropped on the floor today — see the column
reference above. When [#248](https://github.com/dohertj2/lmxopcua/issues/248) reference above. When [#248](https://github.com/dohertj2/lmxopcua/issues/248)
lands a `Description` field on `S7TagDefinition` the importer will start lands a `Description` field on `S7TagDefinition` the importer will start

View File

@@ -90,8 +90,10 @@ not differentiated at test time.
### 5. Data types beyond the scalars ### 5. Data types beyond the scalars
UDT fan-out, `STRING` with length-prefix quirks, `DTL` / `DATE_AND_TIME`, `STRING` with length-prefix quirks, `DTL` / `DATE_AND_TIME`, arrays of
arrays of structs — not covered. structs — not covered. UDT fan-out IS covered (PR-S7-D2 / #300) via the
`udt_layout` meta-seed in `Docker/profiles/s7_1500.json` and the
`Driver_fans_out_udt_into_member_tags` integration test.
## When to trust the S7 tests, when to reach for a rig ## When to trust the S7 tests, when to reach for a rig
@@ -101,7 +103,7 @@ arrays of structs — not covered.
| "Does the driver lifecycle hang / crash?" | yes | yes | | "Does the driver lifecycle hang / crash?" | yes | yes |
| "Does a real read against an S7-1500 return correct bytes?" | no | yes (required) | | "Does a real read against an S7-1500 return correct bytes?" | no | yes (required) |
| "Does mailbox serialization actually prevent PG timeouts?" | no | yes (required) | | "Does mailbox serialization actually prevent PG timeouts?" | no | yes (required) |
| "Does a UDT fan-out produce usable member variables?" | no | yes (required) | | "Does a UDT fan-out produce usable member variables?" | yes (Snap7 + `udt_layout` meta-seed) | yes |
## Follow-up candidates ## Follow-up candidates

View File

@@ -966,6 +966,84 @@ Two surface options:
Full reference: [`docs/drivers/S7-TIA-Import.md`](../drivers/S7-TIA-Import.md). Full reference: [`docs/drivers/S7-TIA-Import.md`](../drivers/S7-TIA-Import.md).
CLI flag table: [`docs/Driver.S7.Cli.md` "import-symbols"](../Driver.S7.Cli.md#import-symbols). CLI flag table: [`docs/Driver.S7.Cli.md` "import-symbols"](../Driver.S7.Cli.md#import-symbols).
## UDT / STRUCT support
PR-S7-D2 / #300 — UDT-typed DBs are exposed via per-member fan-out at driver
init time. The driver reads / writes / subscribes only ever target scalar
leaves; the parent UDT pointer never reaches the wire. This keeps the rest of
the driver pipeline (address parser, block-coalescing planner, scan-group
partitioner, deadband filter) UDT-unaware.
### `S7UdtDefinition`
A UDT is declared once in `S7DriverOptions.Udts` and referenced by tags whose
`UdtName` is set:
```csharp
new S7UdtDefinition(
Name: "Pump",
Members: [
new S7UdtMember("Pressure", Offset: 0, S7DataType.Float32),
new S7UdtMember("Status", Offset: 4, S7DataType.Int16),
new S7UdtMember("Enabled", Offset: 6, S7DataType.Bool),
],
SizeBytes: 7);
```
Tags adopt the UDT layout via `UdtName`:
```csharp
new S7TagDefinition("Pump1", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Pump");
```
### Fan-out semantics
At `InitializeAsync` time the driver:
1. Walks `_options.Tags`. For each tag with `UdtName`, looks up the UDT in
`_options.Udts` (case-insensitive).
2. For each UDT member, computes `parent.Address.ByteOffset + member.Offset`
and emits one scalar `S7TagDefinition` per leaf with name
`Parent.Member` (dot-separated).
3. Array members emit `Member[0]`, `Member[1]`, ... at stride `elementBytes`.
4. Nested UDT members recurse — array-of-UDT walks at stride `inner.SizeBytes`.
5. The fanned-out leaves replace the parent UDT tag in the driver's tag map.
Reads / writes / subscribes that target the parent name surface
`BadNodeIdUnknown` — clients must address the leaves directly.
### 4-level nesting cap
UDT-of-UDT is supported up to 4 levels deep. Anything deeper throws
`InvalidOperationException("UDT nesting depth exceeds 4 levels…")` at Init.
This catches accidentally-recursive declarations early; real industrial UDTs
rarely go beyond 2 layers.
### Optimized block access — must be off
The static-offset model assumes member byte offsets in the declaration match
the runtime layout exactly. TIA Portal's "Optimized block access" flag lets
the runtime reorder members for memory alignment, breaking that assumption.
Same prerequisite as general absolute-offset DB addressing on S7-1200 / 1500:
**Optimized block access must be disabled** on any DB that the driver
addresses by absolute offset, including UDT-typed DBs.
If a customer can't disable Optimized access (e.g., shared-DB constraints),
the workaround is to expose the UDT through the symbolic-tag path once that
ships — not in PR-S7-D2.
### Validation
The fan-out rejects, with clear errors:
- UDT name not found in `Udts` collection
- Member offsets not in ascending order
- Member offsets that overlap (a primitive's `[offset, offset+width)` range
intersects the next member's offset)
- Total members extending past `SizeBytes`
- Tag with `UdtName` AND `ElementCount > 1` (array-of-UDT belongs in the UDT
layout, not at the parent-tag level)
## References ## References
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf 1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf

View File

@@ -96,5 +96,28 @@ $results += Test-SubscribeSeesChange `
-DriverWriteArgs (@("write") + $commonS7 + @("-a", $Address, "-t", "Int16", "-v", $subValue)) ` -DriverWriteArgs (@("write") + $commonS7 + @("-a", $Address, "-t", "Int16", "-v", $subValue)) `
-ExpectedValue "$subValue" -ExpectedValue "$subValue"
# PR-S7-D2 / #300 — UDT-member round-trip. Exercises the byte offsets the
# driver's UDT fan-out uses when expanding a UDT-typed parent tag into per-
# member scalar leaves: Real at DB1.DBD400 and Int16 at DB1.DBW404 match the
# `MyUdt` layout seeded by Docker/profiles/s7_1500.json's udt_layout meta-seed
# and declared by S7_1500UdtFanOutTests. The CLI itself is UDT-unaware so the
# e2e step writes / reads at the explicit member byte offsets — proves the
# wire-level path the fan-out emits is sound end-to-end.
$udtPressureAddress = $Address.Substring(0, $Address.IndexOf('.')) + ".DBD400"
$udtPressureValue = "27.5"
$results += Test-DriverLoopback `
-Cli $s7Cli `
-WriteArgs (@("write") + $commonS7 + @("-a", $udtPressureAddress, "-t", "Float32", "-v", $udtPressureValue)) `
-ReadArgs (@("read") + $commonS7 + @("-a", $udtPressureAddress, "-t", "Float32")) `
-ExpectedValue $udtPressureValue
$udtStatusAddress = $Address.Substring(0, $Address.IndexOf('.')) + ".DBW404"
$udtStatusValue = Get-Random -Minimum 100 -Maximum 999
$results += Test-DriverLoopback `
-Cli $s7Cli `
-WriteArgs (@("write") + $commonS7 + @("-a", $udtStatusAddress, "-t", "Int16", "-v", $udtStatusValue)) `
-ReadArgs (@("read") + $commonS7 + @("-a", $udtStatusAddress, "-t", "Int16")) `
-ExpectedValue "$udtStatusValue"
Write-Summary -Title "S7 e2e" -Results $results Write-Summary -Title "S7 e2e" -Results $results
if ($results | Where-Object { -not $_.Passed }) { exit 1 } if ($results | Where-Object { -not $_.Passed }) { exit 1 }

View File

@@ -2,6 +2,7 @@ using System.Buffers.Binary;
using System.Collections.Generic; using System.Collections.Generic;
using S7.Net; using S7.Net;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7; 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, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = 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 S7DriverOptions _options = options;
private readonly SemaphoreSlim _gate = new(1, 1); 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. // story this lets the Admin UI's "Save" round-trip stay sub-second on bad input.
_tagsByName.Clear(); _tagsByName.Clear();
_parsedByName.Clear(); _parsedByName.Clear();
_effectiveTags.Clear();
foreach (var t in _options.Tags) 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 // Pass CpuType so V-memory addresses (S7-200 / S7-200 Smart / LOGO!) resolve
// against the device's family-specific DB mapping. // against the device's family-specific DB mapping.
var parsed = S7AddressParser.Parse(t.Address, _options.CpuType); // throws FormatException 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; _tagsByName[t.Name] = t;
_parsedByName[t.Name] = parsed; _parsedByName[t.Name] = parsed;
_effectiveTags.Add(t);
} }
var plc = BuildPlc(); 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 // 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. // PDU size from the previous connection. Reinit will repopulate after OpenAsync.
_negotiatedPduSize = 0; _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); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -1019,7 +1063,11 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
{ {
ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(builder);
var folder = builder.Folder("S7", "S7"); 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; var isArr = t.ElementCount is int ec && ec > 1;
folder.Variable(t.Name, t.Name, new DriverAttributeInfo( folder.Variable(t.Name, t.Name, new DriverAttributeInfo(

View File

@@ -86,6 +86,11 @@ public static class S7DriverFactoryExtensions
LocalTsap = dto.LocalTsap, LocalTsap = dto.LocalTsap,
RemoteTsap = dto.RemoteTsap, RemoteTsap = dto.RemoteTsap,
ScanGroupIntervals = scanGroupMap, 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); return new S7Driver(options, driverInstanceId);
@@ -95,16 +100,49 @@ public static class S7DriverFactoryExtensions
new( new(
Name: t.Name ?? throw new InvalidOperationException( Name: t.Name ?? throw new InvalidOperationException(
$"S7 config for '{driverInstanceId}' has a tag missing Name"), $"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( Address: t.Address ?? throw new InvalidOperationException(
$"S7 tag '{t.Name}' in '{driverInstanceId}' missing Address"), $"S7 tag '{t.Name}' in '{driverInstanceId}' missing Address"),
DataType: ParseEnum<S7DataType>(t.DataType, driverInstanceId, "DataType", DataType: string.IsNullOrWhiteSpace(t.UdtName)
tagName: t.Name), ? 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, Writable: t.Writable ?? true,
StringLength: t.StringLength ?? 254, StringLength: t.StringLength ?? 254,
WriteIdempotent: t.WriteIdempotent ?? false, WriteIdempotent: t.WriteIdempotent ?? false,
ScanGroup: string.IsNullOrWhiteSpace(t.ScanGroup) ? null : t.ScanGroup, ScanGroup: string.IsNullOrWhiteSpace(t.ScanGroup) ? null : t.ScanGroup,
DeadbandAbsolute: t.DeadbandAbsolute, 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> /// <summary>
/// PR-S7-D1 / #299 — append TIA Portal "Show all tags" CSV rows to /// PR-S7-D1 / #299 — append TIA Portal "Show all tags" CSV rows to
@@ -225,6 +263,7 @@ public static class S7DriverFactoryExtensions
LocalTsap = options.LocalTsap, LocalTsap = options.LocalTsap,
RemoteTsap = options.RemoteTsap, RemoteTsap = options.RemoteTsap,
ScanGroupIntervals = options.ScanGroupIntervals, 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. /// <c>docs/v2/s7.md</c> "Per-tag scan groups" section.
/// </summary> /// </summary>
public Dictionary<string, int>? ScanGroupIntervalsMs { get; init; } 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 internal sealed class S7TagDto
@@ -320,6 +367,39 @@ public static class S7DriverFactoryExtensions
/// set the filters are OR'd — publish if EITHER threshold triggers. /// set the filters are OR'd — publish if EITHER threshold triggers.
/// </summary> /// </summary>
public double? DeadbandPercent { get; init; } 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 internal sealed class S7ProbeDto

View File

@@ -1,3 +1,4 @@
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
using S7NetCpuType = global::S7.Net.CpuType; using S7NetCpuType = global::S7.Net.CpuType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7; 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. /// batch any more, because the slow batch's <c>Task.Delay</c> isn't holding the gate.
/// </remarks> /// </remarks>
public IReadOnlyDictionary<string, TimeSpan>? ScanGroupIntervals { get; init; } 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> /// <summary>
@@ -285,6 +297,16 @@ public sealed class S7ProbeOptions
/// When both deadbands are set the filters are OR'd — the value publishes if EITHER /// 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>. /// threshold says publish (Kepware-style semantics). See <c>docs/v2/s7.md</c>.
/// </param> /// </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( public sealed record S7TagDefinition(
string Name, string Name,
string Address, string Address,
@@ -295,7 +317,8 @@ public sealed record S7TagDefinition(
int? ElementCount = null, int? ElementCount = null,
string? ScanGroup = null, string? ScanGroup = null,
double? DeadbandAbsolute = null, double? DeadbandAbsolute = null,
double? DeadbandPercent = null); double? DeadbandPercent = null,
string? UdtName = null);
public enum S7DataType 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,
};
}

View File

@@ -19,7 +19,15 @@
{ "_desc": "DB1.DBW100 — scratch for write-then-read round-trip tests; seeded 0", { "_desc": "DB1.DBW100 — scratch for write-then-read round-trip tests; seeded 0",
"offset": 100, "type": "u16", "value": 0 }, "offset": 100, "type": "u16", "value": 0 },
{ "_desc": "DB1.STRING[200] — S7 string 'Hello' (max 32, cur 5)", { "_desc": "DB1.STRING[200] — S7 string 'Hello' (max 32, cur 5)",
"offset": 200, "type": "ascii", "value": "Hello", "max_len": 32 } "offset": 200, "type": "ascii", "value": "Hello", "max_len": 32 },
{ "_desc": "PR-S7-D2: DB1.MyUdt[400].Pressure — Real (Float32) at byte 400",
"offset": 400, "type": "f32", "value": 12.5 },
{ "_desc": "PR-S7-D2: DB1.MyUdt[400].Status — Int16 at byte 404",
"offset": 404, "type": "i16", "value": 7 },
{ "_desc": "PR-S7-D2: DB1.MyUdt[400].Enabled — Bool at byte 406 bit 0 (true)",
"offset": 406, "type": "bool", "value": true, "bit": 0 },
{ "_desc": "PR-S7-D2: DB1.MyUdt[400] meta — udt_layout marker for the seed reader (3 members, 7 bytes total)",
"offset": 407, "type": "u8", "value": 3 }
] ]
}, },
{ {

View File

@@ -45,10 +45,39 @@ def seed_buffer(buf: bytearray, seeds: list[dict]) -> None:
"""Poke seed values into the area buffer at declared byte offsets. """Poke seed values into the area buffer at declared byte offsets.
Each seed is {"offset": int, "type": str, "value": int|float|bool|str} Each seed is {"offset": int, "type": str, "value": int|float|bool|str}
where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii}. Endianness is where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii, udt_layout}.
big-endian (Siemens wire format). Endianness is big-endian (Siemens wire format).
PR-S7-D2: ``udt_layout`` is a meta-seed-type that flattens an ordered list
of UDT members into per-member primitive seeds at member-byte offsets
relative to the parent's ``offset``. Shape:
{
"offset": 400, "type": "udt_layout",
"members": [
{"name": "Pressure", "offset": 0, "type": "f32", "value": 12.5},
{"name": "Status", "offset": 4, "type": "i16", "value": 7},
{"name": "Enabled", "offset": 6, "type": "bool", "value": true, "bit": 0}
]
}
Members reuse the same primitive seed types so the simulator stays
one-pass — ``udt_layout`` is sugar that lets the JSON profile read like
the UDT layout the .NET driver fan-outs into.
""" """
for seed in seeds: for seed in seeds:
# PR-S7-D2: expand udt_layout meta-seeds inline before the per-type
# dispatch so members hit the same primitive paths as a flat seed list.
if seed.get("type") == "udt_layout":
base = int(seed["offset"])
members = seed.get("members", [])
expanded = []
for m in members:
child = dict(m)
child["offset"] = base + int(m.get("offset", 0))
expanded.append(child)
seed_buffer(buf, expanded)
continue
off = int(seed["offset"]) off = int(seed["offset"])
t = seed["type"] t = seed["type"]
v = seed["value"] v = seed["value"]

View File

@@ -0,0 +1,85 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
using S7NetCpuType = global::S7.Net.CpuType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// PR-S7-D2 — UDT fan-out integration test against the python-snap7 S7-1500 fixture.
/// Seeds a 3-member UDT (Real + Int + Bool) into <c>DB1.MyUdt[400]</c> via
/// <c>Docker/profiles/s7_1500.json</c>'s <c>udt_layout</c> meta-seed, declares the
/// same layout in driver options, and verifies the fanned-out leaf reads return the
/// seeded values end-to-end through real S7comm. Build-only by default — the
/// simulator fixture skips when python-snap7 isn't running, so this test contributes
/// to the CI matrix without requiring docker locally.
/// </summary>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500UdtFanOutTests(Snap7ServerFixture sim)
{
private const string ParentTagName = "MyUdt";
/// <summary>
/// UDT layout matching the <c>udt_layout</c> meta-seed in the JSON profile.
/// Pressure (Real) at byte 0, Status (Int16) at byte 4, Enabled (Bool) at byte 6.
/// </summary>
private static readonly S7UdtDefinition MyUdt = new(
Name: "MyUdt",
Members:
[
new S7UdtMember("Pressure", 0, S7DataType.Float32),
new S7UdtMember("Status", 4, S7DataType.Int16),
new S7UdtMember("Enabled", 6, S7DataType.Bool),
],
SizeBytes: 7);
private static S7DriverOptions BuildUdtOptions(string host, int port) => new()
{
Host = host,
Port = port,
CpuType = S7NetCpuType.S71500,
Timeout = TimeSpan.FromSeconds(5),
Probe = new S7ProbeOptions { Enabled = false },
Tags =
[
// Parent UDT tag — base address points at byte 400 in DB1, where the
// simulator seeded the UDT contents. Fan-out emits three scalar leaves:
// MyUdt.Pressure -> DB1.DBD400 (Real 12.5)
// MyUdt.Status -> DB1.DBW404 (Int16 7)
// MyUdt.Enabled -> DB1.DBX406.0 (Bool true)
new S7TagDefinition(ParentTagName, "DB1.DBX400.0", S7DataType.Byte, UdtName: "MyUdt"),
],
Udts = [MyUdt],
};
[Fact]
public async Task Driver_fans_out_udt_into_member_tags()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var options = BuildUdtOptions(sim.Host, sim.Port);
await using var drv = new S7Driver(options, driverInstanceId: "s7-udt-fanout");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// After fan-out the parent UDT name is gone from the tag map; only the leaves
// are readable. Reading the parent should surface BadNodeIdUnknown.
var parent = await drv.ReadAsync([ParentTagName], TestContext.Current.CancellationToken);
parent[0].StatusCode.ShouldNotBe(0u, "parent UDT tag must be replaced by its leaves");
// Read the three leaves and assert the seeded values come back.
var leaves = await drv.ReadAsync(
["MyUdt.Pressure", "MyUdt.Status", "MyUdt.Enabled"],
TestContext.Current.CancellationToken);
leaves.Count.ShouldBe(3);
foreach (var s in leaves)
s.StatusCode.ShouldBe(0u, "every UDT leaf read must succeed end-to-end");
Convert.ToSingle(leaves[0].Value).ShouldBe(12.5f, tolerance: 0.0001f);
Convert.ToInt32(leaves[1].Value).ShouldBe(7);
Convert.ToBoolean(leaves[2].Value).ShouldBeTrue();
}
}

View File

@@ -0,0 +1,363 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// PR-S7-D2 — UDT / STRUCT fan-out unit tests. Verifies the recursive flattening of
/// UDT-typed parent tags into scalar leaf member tags happens correctly at
/// <see cref="S7Driver.InitializeAsync"/> time, that the depth cap (4 levels) is
/// enforced, and that misordered / overlapping / unknown UDT references fail with
/// the expected error shape.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7UdtFanOutTests
{
private static S7DriverOptions OptionsWith(IReadOnlyList<S7TagDefinition> tags, IReadOnlyList<S7UdtDefinition> udts)
=> new()
{
Host = "192.0.2.1", // RFC 5737 reserved — never reachable; we only exercise the parse + fan-out path.
Timeout = TimeSpan.FromMilliseconds(50),
Tags = tags,
Udts = udts,
Probe = new S7ProbeOptions { Enabled = false },
};
/// <summary>
/// Run InitializeAsync against an unreachable host so the parse / fan-out validation
/// happens before the TCP connect attempt fails. Returns the exception so tests can
/// assert on its shape; throws if the unexpected exception type bubbled up.
/// </summary>
private static async Task<Exception> InitAndCaptureFanOutErrorAsync(S7DriverOptions opts)
{
using var drv = new S7Driver(opts, "s7-udt-test");
return await Should.ThrowAsync<Exception>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
}
/// <summary>
/// Direct-call helper that bypasses InitializeAsync's TCP connect: invokes the
/// fan-out helper directly so the test can inspect the produced leaf tag list
/// without spinning up a Plc.
/// </summary>
private static IReadOnlyList<S7TagDefinition> FanOut(
S7TagDefinition parent, IReadOnlyList<S7UdtDefinition> udts)
{
var parsed = S7AddressParser.Parse(parent.Address);
return S7UdtFanOut.Expand(parent, udts, parsed);
}
[Fact]
public void Single_level_UDT_with_three_scalar_members_emits_three_tags()
{
var udt = new S7UdtDefinition("Sensor", new[]
{
new S7UdtMember("Pressure", 0, S7DataType.Float32),
new S7UdtMember("Status", 4, S7DataType.Int16),
new S7UdtMember("Enabled", 6, S7DataType.Bool),
}, SizeBytes: 8);
var parent = new S7TagDefinition("MySensor", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Sensor");
var leaves = FanOut(parent, [udt]);
leaves.Count.ShouldBe(3);
leaves[0].Name.ShouldBe("MySensor.Pressure");
leaves[0].Address.ShouldBe("DB1.DBD0");
leaves[0].DataType.ShouldBe(S7DataType.Float32);
leaves[1].Name.ShouldBe("MySensor.Status");
leaves[1].Address.ShouldBe("DB1.DBW4");
leaves[2].Name.ShouldBe("MySensor.Enabled");
leaves[2].Address.ShouldBe("DB1.DBX6.0");
}
[Fact]
public void Nested_UDT_recursively_flattens_with_dot_separated_paths()
{
var inner = new S7UdtDefinition("Telemetry", new[]
{
new S7UdtMember("Pressure", 0, S7DataType.Float32),
new S7UdtMember("Temperature", 4, S7DataType.Float32),
}, SizeBytes: 8);
var outer = new S7UdtDefinition("Pump", new[]
{
new S7UdtMember("Telemetry", 0, S7DataType.Byte, UdtName: "Telemetry"),
new S7UdtMember("Speed", 8, S7DataType.Int16),
}, SizeBytes: 10);
var parent = new S7TagDefinition("Pump1", "DB2.DBX0.0", S7DataType.Byte, UdtName: "Pump");
var leaves = FanOut(parent, [outer, inner]);
leaves.Count.ShouldBe(3);
leaves[0].Name.ShouldBe("Pump1.Telemetry.Pressure");
leaves[0].Address.ShouldBe("DB2.DBD0");
leaves[1].Name.ShouldBe("Pump1.Telemetry.Temperature");
leaves[1].Address.ShouldBe("DB2.DBD4");
leaves[2].Name.ShouldBe("Pump1.Speed");
leaves[2].Address.ShouldBe("DB2.DBW8");
}
[Fact]
public void Four_level_nesting_succeeds_at_the_cap()
{
// L4 is the deepest level the cap allows.
var l4 = new S7UdtDefinition("L4", [new S7UdtMember("Leaf", 0, S7DataType.Int16)], SizeBytes: 2);
var l3 = new S7UdtDefinition("L3", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L4")], SizeBytes: 2);
var l2 = new S7UdtDefinition("L2", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L3")], SizeBytes: 2);
var l1 = new S7UdtDefinition("L1", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L2")], SizeBytes: 2);
var parent = new S7TagDefinition("Root", "DB1.DBX0.0", S7DataType.Byte, UdtName: "L1");
var leaves = FanOut(parent, [l1, l2, l3, l4]);
leaves.Count.ShouldBe(1);
leaves[0].Name.ShouldBe("Root.X.X.X.Leaf");
}
[Fact]
public void Five_level_nesting_throws_with_clear_error()
{
// Add a fifth level — exceeds the depth cap.
var l5 = new S7UdtDefinition("L5", [new S7UdtMember("Leaf", 0, S7DataType.Int16)], SizeBytes: 2);
var l4 = new S7UdtDefinition("L4", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L5")], SizeBytes: 2);
var l3 = new S7UdtDefinition("L3", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L4")], SizeBytes: 2);
var l2 = new S7UdtDefinition("L2", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L3")], SizeBytes: 2);
var l1 = new S7UdtDefinition("L1", [new S7UdtMember("X", 0, S7DataType.Byte, UdtName: "L2")], SizeBytes: 2);
var parent = new S7TagDefinition("Root", "DB1.DBX0.0", S7DataType.Byte, UdtName: "L1");
var ex = Should.Throw<InvalidOperationException>(() => FanOut(parent, [l1, l2, l3, l4, l5]));
ex.Message.ShouldContain("nesting depth", Case.Insensitive);
ex.Message.ShouldContain("4");
}
[Fact]
public void Reference_to_unknown_UDT_throws_with_clear_error()
{
var parent = new S7TagDefinition("MissingUdtTag", "DB1.DBX0.0", S7DataType.Byte, UdtName: "DoesNotExist");
var ex = Should.Throw<InvalidOperationException>(() => FanOut(parent, []));
ex.Message.ShouldContain("DoesNotExist");
ex.Message.ShouldContain("MissingUdtTag");
ex.Message.ShouldContain("not declared", Case.Insensitive);
}
[Fact]
public void Misordered_member_offsets_throw_at_fan_out()
{
var udt = new S7UdtDefinition("Bad", new[]
{
new S7UdtMember("First", 4, S7DataType.Int16),
new S7UdtMember("Second", 0, S7DataType.Int16), // earlier byte than First.
}, SizeBytes: 8);
var parent = new S7TagDefinition("T", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Bad");
var ex = Should.Throw<InvalidOperationException>(() => FanOut(parent, [udt]));
ex.Message.ShouldContain("misordered", Case.Insensitive);
}
[Fact]
public void Overlapping_member_offsets_throw_at_fan_out()
{
// Float32 at offset 0 occupies bytes 0..3; Int16 at offset 2 overlaps.
var udt = new S7UdtDefinition("Overlap", new[]
{
new S7UdtMember("Wide", 0, S7DataType.Float32),
new S7UdtMember("Narrow", 2, S7DataType.Int16),
}, SizeBytes: 8);
var parent = new S7TagDefinition("T", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Overlap");
var ex = Should.Throw<InvalidOperationException>(() => FanOut(parent, [udt]));
ex.Message.ShouldContain("overlap", Case.Insensitive);
}
[Fact]
public void Array_member_with_three_elements_emits_three_indexed_sub_tags()
{
var udt = new S7UdtDefinition("Bank", new[]
{
new S7UdtMember("Sensors", 0, S7DataType.Float32, ArrayDim: 3),
new S7UdtMember("Count", 12, S7DataType.Int16),
}, SizeBytes: 14);
var parent = new S7TagDefinition("Bank1", "DB3.DBX0.0", S7DataType.Byte, UdtName: "Bank");
var leaves = FanOut(parent, [udt]);
leaves.Count.ShouldBe(4);
leaves[0].Name.ShouldBe("Bank1.Sensors[0]");
leaves[0].Address.ShouldBe("DB3.DBD0");
leaves[1].Name.ShouldBe("Bank1.Sensors[1]");
leaves[1].Address.ShouldBe("DB3.DBD4");
leaves[2].Name.ShouldBe("Bank1.Sensors[2]");
leaves[2].Address.ShouldBe("DB3.DBD8");
leaves[3].Name.ShouldBe("Bank1.Count");
leaves[3].Address.ShouldBe("DB3.DBW12");
}
[Fact]
public void DTO_round_trip_preserves_UdtName_and_Udts_collection()
{
var dto = new S7DriverFactoryExtensions.S7DriverConfigDto
{
Host = "10.0.0.5",
Tags =
[
new S7DriverFactoryExtensions.S7TagDto
{
Name = "Pump1",
Address = "DB1.DBX0.0",
DataType = "Byte",
UdtName = "Pump",
}
],
Udts =
[
new S7DriverFactoryExtensions.S7UdtDto
{
Name = "Pump",
SizeBytes = 8,
Members =
[
new S7DriverFactoryExtensions.S7UdtMemberDto
{
Name = "Pressure", Offset = 0, DataType = "Float32"
},
new S7DriverFactoryExtensions.S7UdtMemberDto
{
Name = "Status", Offset = 4, DataType = "Int16"
},
],
}
],
};
var json = JsonSerializer.Serialize(dto);
var back = JsonSerializer.Deserialize<S7DriverFactoryExtensions.S7DriverConfigDto>(json)!;
back.Tags.ShouldNotBeNull();
back.Tags!.Count.ShouldBe(1);
back.Tags[0].UdtName.ShouldBe("Pump");
back.Udts.ShouldNotBeNull();
back.Udts!.Count.ShouldBe(1);
back.Udts[0].Name.ShouldBe("Pump");
back.Udts[0].SizeBytes.ShouldBe(8);
back.Udts[0].Members!.Count.ShouldBe(2);
back.Udts[0].Members![0].Name.ShouldBe("Pressure");
back.Udts[0].Members![0].DataType.ShouldBe("Float32");
}
[Fact]
public void Tag_with_both_UdtName_and_DataType_uses_UdtName()
{
// When both are set, UdtName wins — the primitive DataType is ignored because the
// parent tag is replaced wholesale by its scalar leaves at fan-out time.
var udt = new S7UdtDefinition("Tiny", [new S7UdtMember("V", 0, S7DataType.Int16)], SizeBytes: 2);
// Parent declares DataType=Float32 and UdtName=Tiny. The leaf comes out as Int16 (per UDT),
// not Float32, proving UdtName trumps the primitive DataType.
var parent = new S7TagDefinition("Either", "DB1.DBX0.0", S7DataType.Float32, UdtName: "Tiny");
var leaves = FanOut(parent, [udt]);
leaves.Count.ShouldBe(1);
leaves[0].DataType.ShouldBe(S7DataType.Int16);
leaves[0].Name.ShouldBe("Either.V");
}
[Fact]
public async Task Initialize_with_UDT_tag_replaces_parent_with_leaves_in_address_space()
{
// End-to-end driver path: fan-out should populate the discovery folder with the
// leaves, NOT the parent. Initialize will fail at TCP connect (unreachable host),
// but only AFTER the parse + fan-out has populated the tag map.
var udt = new S7UdtDefinition("Sensor", new[]
{
new S7UdtMember("Pressure", 0, S7DataType.Float32),
new S7UdtMember("Status", 4, S7DataType.Int16),
}, SizeBytes: 6);
var opts = OptionsWith(
tags: [new S7TagDefinition("MySensor", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Sensor")],
udts: [udt]);
var ex = await InitAndCaptureFanOutErrorAsync(opts);
// The fan-out itself didn't throw (UDT was found, layout valid) — only the TCP connect
// failed with a socket / timeout exception. That's the expected shape.
ex.ShouldNotBeOfType<InvalidOperationException>();
}
[Fact]
public async Task Initialize_with_UDT_referencing_unknown_layout_fails_at_fan_out()
{
var opts = OptionsWith(
tags: [new S7TagDefinition("MissingUdtTag", "DB1.DBX0.0", S7DataType.Byte, UdtName: "DoesNotExist")],
udts: []);
var ex = await InitAndCaptureFanOutErrorAsync(opts);
ex.ShouldBeOfType<InvalidOperationException>();
ex.Message.ShouldContain("DoesNotExist");
}
[Fact]
public async Task Initialize_rejects_UDT_tag_with_ElementCount_greater_than_one()
{
var udt = new S7UdtDefinition("Tiny", [new S7UdtMember("V", 0, S7DataType.Int16)], SizeBytes: 2);
var opts = OptionsWith(
tags: [new S7TagDefinition("BadArrayUdt", "DB1.DBX0.0", S7DataType.Byte,
ElementCount: 5, UdtName: "Tiny")],
udts: [udt]);
var ex = await InitAndCaptureFanOutErrorAsync(opts);
ex.ShouldBeOfType<FormatException>();
ex.Message.ShouldContain("array-of-UDT", Case.Insensitive);
}
[Fact]
public void Array_of_nested_UDT_strides_member_addresses_by_inner_size()
{
// Inner UDT is 4 bytes wide (one Float32). An array of 3 of these strides 4 bytes
// per element. Outer UDT places the array at offset 0, so the leaves should be at
// bytes 0, 4, 8.
var inner = new S7UdtDefinition("Reading", [new S7UdtMember("Value", 0, S7DataType.Float32)], SizeBytes: 4);
var outer = new S7UdtDefinition("Channel", new[]
{
new S7UdtMember("Readings", 0, S7DataType.Byte, ArrayDim: 3, UdtName: "Reading"),
}, SizeBytes: 12);
var parent = new S7TagDefinition("Ch1", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Channel");
var leaves = FanOut(parent, [outer, inner]);
leaves.Count.ShouldBe(3);
leaves[0].Name.ShouldBe("Ch1.Readings[0].Value");
leaves[0].Address.ShouldBe("DB1.DBD0");
leaves[1].Name.ShouldBe("Ch1.Readings[1].Value");
leaves[1].Address.ShouldBe("DB1.DBD4");
leaves[2].Name.ShouldBe("Ch1.Readings[2].Value");
leaves[2].Address.ShouldBe("DB1.DBD8");
}
[Fact]
public void Members_extending_past_SizeBytes_throw()
{
// A 4-byte Float32 at offset 6 ends at 10, but SizeBytes claims 8.
var udt = new S7UdtDefinition("Cramped", new[]
{
new S7UdtMember("X", 6, S7DataType.Float32),
}, SizeBytes: 8);
var parent = new S7TagDefinition("T", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Cramped");
var ex = Should.Throw<InvalidOperationException>(() => FanOut(parent, [udt]));
ex.Message.ShouldContain("SizeBytes", Case.Insensitive);
}
}