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