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

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

View File

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

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