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

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

View File

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