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

@@ -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 }
]
},
{

View File

@@ -45,10 +45,39 @@ def seed_buffer(buf: bytearray, seeds: list[dict]) -> None:
"""Poke seed values into the area buffer at declared byte offsets.
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"]

View File

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