From 5f8d84db43142cb2ac1a630bf1db1b69562a0bf6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 06:50:26 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20s7-d2=20=E2=80=94=20UDT=20/=20STRUCT=20?= =?UTF-8?q?/=20nested-DB=20fan-out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #300 --- docs/drivers/S7-TIA-Import.md | 39 +- docs/drivers/S7-Test-Fixture.md | 8 +- docs/v2/s7.md | 78 ++++ scripts/e2e/test-s7.ps1 | 23 ++ src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 50 ++- .../S7DriverFactoryExtensions.cs | 86 ++++- .../S7DriverOptions.cs | 25 +- .../SymbolImport/S7UdtDefinition.cs | 50 +++ .../SymbolImport/S7UdtFanOut.cs | 305 +++++++++++++++ .../Docker/profiles/s7_1500.json | 10 +- .../Docker/server.py | 33 +- .../S7_1500/S7_1500UdtFanOutTests.cs | 85 ++++ .../S7UdtFanOutTests.cs | 363 ++++++++++++++++++ 13 files changed, 1139 insertions(+), 16 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/S7UdtDefinition.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/S7UdtFanOut.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500UdtFanOutTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7UdtFanOutTests.cs diff --git a/docs/drivers/S7-TIA-Import.md b/docs/drivers/S7-TIA-Import.md index f933e76..1dfb789 100644 --- a/docs/drivers/S7-TIA-Import.md +++ b/docs/drivers/S7-TIA-Import.md @@ -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`) 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` -and the row is marked `Writable = false`. PR-S7-D2 will replace the placeholder -with proper UDT layout once the symbol table covers nested struct fields. +and the row is marked `Writable = false`. `S7ImportResult.UdtPlaceholderCount` tracks how many of the imported tags 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 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 `replace=true` switch. - 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 - primitive field); operators should not bind dependent client tags to - placeholder rows because the addresses will be rewritten when D2 lands. + added the runtime UDT fan-out (`S7DriverOptions.Udts` + `S7TagDefinition.UdtName`) + — operators upgrade a placeholder row by setting `UdtName` and declaring the + 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 reference above. When [#248](https://github.com/dohertj2/lmxopcua/issues/248) lands a `Description` field on `S7TagDefinition` the importer will start diff --git a/docs/drivers/S7-Test-Fixture.md b/docs/drivers/S7-Test-Fixture.md index cbac804..8113cf1 100644 --- a/docs/drivers/S7-Test-Fixture.md +++ b/docs/drivers/S7-Test-Fixture.md @@ -90,8 +90,10 @@ not differentiated at test time. ### 5. Data types beyond the scalars -UDT fan-out, `STRING` with length-prefix quirks, `DTL` / `DATE_AND_TIME`, -arrays of structs — not covered. +`STRING` with length-prefix quirks, `DTL` / `DATE_AND_TIME`, arrays of +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 @@ -101,7 +103,7 @@ arrays of structs — not covered. | "Does the driver lifecycle hang / crash?" | yes | yes | | "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 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 diff --git a/docs/v2/s7.md b/docs/v2/s7.md index 86f4cff..5084404 100644 --- a/docs/v2/s7.md +++ b/docs/v2/s7.md @@ -966,6 +966,84 @@ Two surface options: 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). +## 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 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 diff --git a/scripts/e2e/test-s7.ps1 b/scripts/e2e/test-s7.ps1 index 2060d8c..906063a 100644 --- a/scripts/e2e/test-s7.ps1 +++ b/scripts/e2e/test-s7.ps1 @@ -96,5 +96,28 @@ $results += Test-SubscribeSeesChange ` -DriverWriteArgs (@("write") + $commonS7 + @("-a", $Address, "-t", "Int16", "-v", $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 if ($results | Where-Object { -not $_.Passed }) { exit 1 } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index bc2f30e..41a3469 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -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 _tagsByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _parsedByName = new(StringComparer.OrdinalIgnoreCase); + /// + /// PR-S7-D2 — post-fan-out tag list, in declaration order. Mirrors + /// as a list so 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. + /// + private readonly List _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)_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( diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs index 1bbdaec..5ecc997 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverFactoryExtensions.cs @@ -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(t.DataType, driverInstanceId, "DataType", - tagName: t.Name), + DataType: string.IsNullOrWhiteSpace(t.UdtName) + ? ParseEnum(t.DataType, driverInstanceId, "DataType", tagName: t.Name) + : ParseEnum(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()).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(m.DataType, driverInstanceId, $"UDT '{udtName}' member '{m.Name}' DataType") + : ParseEnum(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); + } /// /// 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 /// docs/v2/s7.md "Per-tag scan groups" section. /// public Dictionary? ScanGroupIntervalsMs { get; init; } + + /// + /// PR-S7-D2 — UDT / STRUCT layout declarations. Tags whose + /// matches a UDT here get fanned out into + /// scalar leaf member tags at S7Driver.InitializeAsync time. + /// See docs/v2/s7.md "UDT / STRUCT support" section. + /// + public List? 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. /// public double? DeadbandPercent { get; init; } + + /// + /// PR-S7-D2 — when set, the tag is a UDT / STRUCT-typed pointer. The driver + /// looks up the named UDT in and fans the + /// tag out into scalar leaf tags at InitializeAsync time. The primitive + /// field is ignored when UdtName is set; the + /// leaves take their data types from the UDT member declarations. + /// + public string? UdtName { get; init; } + } + + /// + /// PR-S7-D2 — JSON wire form for . See + /// docs/v2/s7.md "UDT / STRUCT support" for the round-trip example. + /// + internal sealed class S7UdtDto + { + public string? Name { get; init; } + public List? Members { get; init; } + public int? SizeBytes { get; init; } + } + + /// + /// PR-S7-D2 — JSON wire form for . UdtName is set + /// for nested-UDT members; DataType is set for primitive members. + /// + 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 diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs index 63e88ee..867457c 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs @@ -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 Task.Delay isn't holding the gate. /// public IReadOnlyDictionary? ScanGroupIntervals { get; init; } + + /// + /// PR-S7-D2 — UDT / STRUCT layout declarations referenced by tags whose + /// is set. At + /// time the driver fans every UDT-typed tag into N scalar leaf-member tags whose + /// addresses equal parent.Address + member.Offset; reads / writes / subscribes + /// never see the parent UDT tag, so the rest of the pipeline stays scalar-only. + /// See docs/v2/s7.md "UDT / STRUCT support" section for the full semantics + /// including the 4-level nesting cap and the Optimized-DB prerequisite. + /// + public IReadOnlyList Udts { get; init; } = []; } /// @@ -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 docs/v2/s7.md. /// +/// +/// PR-S7-D2 — when set, this tag is a UDT / STRUCT-typed pointer. The driver looks up +/// the named UDT in and fans the tag out into N scalar +/// member tags at time. is +/// ignored when is set (the parent UDT tag never reaches the +/// read/write code path; only its scalar leaves do). Mutually exclusive with +/// > 1 — array-of-UDT is supported via array members +/// inside the UDT layout, not at the parent-tag level. See docs/v2/s7.md +/// "UDT / STRUCT support" section. +/// 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 { diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/S7UdtDefinition.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/S7UdtDefinition.cs new file mode 100644 index 0000000..0b9ca70 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/S7UdtDefinition.cs @@ -0,0 +1,50 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport; + +/// +/// PR-S7-D2 — one named member of an . 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. +/// +/// Member name; concatenated with the parent tag's name via dot-separator at fan-out. +/// Byte offset within the parent UDT. Must be ascending and non-overlapping across the member list. +/// Primitive S7 type. Ignored when is set (nested-UDT case). +/// Optional 1-D array length. null / 1 = scalar; > 1 emits indexed sub-tags Member[0], Member[1], ... +/// 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 . +public sealed record S7UdtMember( + string Name, + int Offset, + S7DataType DataType, + int? ArrayDim = null, + string? UdtName = null); + +/// +/// PR-S7-D2 — declarative description of a Siemens UDT (User-Defined Type) / +/// STRUCT layout. Tags whose matches +/// get fanned-out into one scalar leaf tag per recursive +/// member at time, so the rest of the +/// read/write/subscribe pipeline never has to know about UDTs. +/// +/// UDT name. Case-insensitively matched against and against nested members' . +/// Ordered member list. Members must have ascending non-overlapping offsets; offsets that re-use bytes are rejected at fan-out time. +/// Total UDT byte size, used as a sanity bound for the last member's offset + width. +/// +/// +/// Optimized block access: 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 docs/v2/s7.md). +/// +/// +/// Nesting depth: nested UDT-of-UDT is supported up to 4 levels. +/// The fan-out throws on the 5th +/// level — picked as a generous-but-still-bounded ceiling that catches +/// pathological / accidentally-recursive declarations early. +/// +/// +public sealed record S7UdtDefinition( + string Name, + IReadOnlyList Members, + int SizeBytes); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/S7UdtFanOut.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/S7UdtFanOut.cs new file mode 100644 index 0000000..3ae0783 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/S7UdtFanOut.cs @@ -0,0 +1,305 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport; + +/// +/// PR-S7-D2 — UDT / STRUCT fan-out helper. Walks a parent +/// whose is set, looks up the matching +/// , 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. +/// +/// +/// +/// The fan-out happens once at 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. +/// +/// +/// Naming is dot-separated: ParentTag.Member.SubMember. Array members +/// emit indexed children (ParentTag.Sensors[0], ParentTag.Sensors[1], ...). +/// 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 +/// and re-rendering the address string in +/// a width-suffix form the standard will re-parse +/// without modification. +/// +/// +internal static class S7UdtFanOut +{ + /// + /// 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. + /// + public const int MaxNestingDepth = 4; + + /// + /// Fan a parent UDT-typed tag into N scalar member tags, emitting one scalar + /// per leaf member. + /// + public static IReadOnlyList Expand( + S7TagDefinition parent, + IReadOnlyList 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(); + 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 BuildUdtIndex(IReadOnlyList udts) + { + var dict = new Dictionary(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 udtIndex, + int depth, + List 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 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; + } + + /// + /// 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 . + /// + private static void ValidateMemberLayout(S7UdtDefinition udt, string tagPath) + { + var prevEnd = -1; + var prevName = ""; + 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); + } + + /// + /// 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 so the standard parser will accept it on the + /// return trip. + /// + 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}"), + }; + + /// + /// 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. + /// + 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, + }; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json index 20d8955..2a4f257 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json @@ -19,7 +19,15 @@ { "_desc": "DB1.DBW100 — scratch for write-then-read round-trip tests; seeded 0", "offset": 100, "type": "u16", "value": 0 }, { "_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 } ] }, { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py index ce1824b..7768029 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py @@ -45,10 +45,39 @@ def seed_buffer(buf: bytearray, seeds: list[dict]) -> None: """Poke seed values into the area buffer at declared byte offsets. 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 - big-endian (Siemens wire format). + where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii, udt_layout}. + 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: + # 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"]) t = seed["type"] v = seed["value"] diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500UdtFanOutTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500UdtFanOutTests.cs new file mode 100644 index 0000000..0fa622b --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500UdtFanOutTests.cs @@ -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; + +/// +/// PR-S7-D2 — UDT fan-out integration test against the python-snap7 S7-1500 fixture. +/// Seeds a 3-member UDT (Real + Int + Bool) into DB1.MyUdt[400] via +/// Docker/profiles/s7_1500.json's udt_layout 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. +/// +[Collection(Snap7ServerCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Device", "S7_1500")] +public sealed class S7_1500UdtFanOutTests(Snap7ServerFixture sim) +{ + private const string ParentTagName = "MyUdt"; + + /// + /// UDT layout matching the udt_layout meta-seed in the JSON profile. + /// Pressure (Real) at byte 0, Status (Int16) at byte 4, Enabled (Bool) at byte 6. + /// + 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(); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7UdtFanOutTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7UdtFanOutTests.cs new file mode 100644 index 0000000..7a2e91c --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7UdtFanOutTests.cs @@ -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; + +/// +/// 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 +/// time, that the depth cap (4 levels) is +/// enforced, and that misordered / overlapping / unknown UDT references fail with +/// the expected error shape. +/// +[Trait("Category", "Unit")] +public sealed class S7UdtFanOutTests +{ + private static S7DriverOptions OptionsWith(IReadOnlyList tags, IReadOnlyList 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 }, + }; + + /// + /// 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. + /// + private static async Task InitAndCaptureFanOutErrorAsync(S7DriverOptions opts) + { + using var drv = new S7Driver(opts, "s7-udt-test"); + return await Should.ThrowAsync(async () => + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); + } + + /// + /// 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. + /// + private static IReadOnlyList FanOut( + S7TagDefinition parent, IReadOnlyList 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(() => 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(() => 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(() => 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(() => 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(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(); + } + + [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(); + 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(); + 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(() => FanOut(parent, [udt])); + ex.Message.ShouldContain("SizeBytes", Case.Insensitive); + } +}