fix(s7): refuse unservable tag shapes per tag at every gate (R07:H3, R07:U4)
The init-time tag guards THREW: one mis-authored tag (DB1.DBZ0, DB1.DBW0 typed Float64, T5 typed Int32, a writable Bool array) faulted the whole driver and the actor retried the same init every 10 s forever, darkening every tag on the PLC. Neither the editor nor the deploy gate checked any of it. - S7AddressParser moves to S7.Contracts (same namespace) and a new S7TagShapeRules there is the single authority on servable shapes. - S7TagDefinitionFactory.FromTagConfig applies it, so the AdminUI editor, the deploy gate (Inspect, with the specific reason) and the driver init refuse the same tags. Init now skips a refused tag (ISkippedTagReporter) instead of throwing; the throwing RejectUnsupportedTagConfigs is gone. - New rules on top of the old guards: a narrow type at the wrong access width (every read failed) and a Bool array not starting at bit 0 (U4: DB1.DBX0.3 silently read bit 0 of bytes 0..n). - AdminUI test fixtures that used addresses the driver could never serve (DB1.DBS0, a browse "SomeAddress", Int32/Float32 on DBW) now use real ones. Refs #742 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+57
@@ -0,0 +1,57 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// Live gate for R07:H3 — one mis-authored tag must not darken the PLC. Before the fix each of
|
||||
/// these shapes threw at init, so the driver Faulted and served nothing (and the actor retried the
|
||||
/// same init every 10 s forever). Now each is skipped on its own and the good tags keep reading
|
||||
/// their seeded values over real S7comm.
|
||||
/// </summary>
|
||||
[Collection(Snap7ServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7_1500")]
|
||||
public sealed class S7_1500TagShapeSkipTests(Snap7ServerFixture sim)
|
||||
{
|
||||
/// <summary>Unservable tags are skipped per tag; the seeded neighbours still read Good.</summary>
|
||||
[Fact]
|
||||
public async Task Mis_authored_tags_are_skipped_and_the_rest_of_the_PLC_still_reads()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var good = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
|
||||
var bad = new[]
|
||||
{
|
||||
new S7TagDefinition("Bad/Typo", "DB1.DBZ0", S7DataType.Int16),
|
||||
new S7TagDefinition("Bad/WideOnWord", "DB1.DBW0", S7DataType.Float64),
|
||||
new S7TagDefinition("Bad/TimerAsInt", "T5", S7DataType.Int32, Writable: false),
|
||||
new S7TagDefinition("Bad/OffsetBoolArray", "DB1.DBX50.3", S7DataType.Bool, Writable: false, ArrayCount: 8),
|
||||
};
|
||||
var options = new S7DriverOptions
|
||||
{
|
||||
Host = good.Host, Port = good.Port, CpuType = good.CpuType, Rack = good.Rack, Slot = good.Slot,
|
||||
Timeout = good.Timeout, Probe = good.Probe,
|
||||
RawTags = [.. good.RawTags, .. S7RawTags.Entries(bad)],
|
||||
};
|
||||
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-shape-skip-live");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetSkippedTags().ShouldNotBeNull().Sample.Select(s => s.RawPath)
|
||||
.ShouldBe(bad.Select(b => b.Name), ignoreOrder: true);
|
||||
|
||||
var r = await drv.ReadAsync(
|
||||
[S7_1500Profile.ProbeTag, S7_1500Profile.SmokeI16Tag, S7_1500Profile.SmokeBoolTag, bad[0].Name], ct);
|
||||
r[0].StatusCode.ShouldBe(0u);
|
||||
Convert.ToInt32(r[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
|
||||
r[1].StatusCode.ShouldBe(0u);
|
||||
Convert.ToInt32(r[1].Value).ShouldBe((int)S7_1500Profile.SmokeI16SeedValue);
|
||||
r[2].StatusCode.ShouldBe(0u);
|
||||
Convert.ToBoolean(r[2].Value).ShouldBeTrue();
|
||||
r[3].StatusCode.ShouldBe(0x80340000u, "a skipped tag reads BadNodeIdUnknown");
|
||||
}
|
||||
}
|
||||
@@ -25,26 +25,10 @@ public sealed class S7DriverCodeReviewFixTests
|
||||
[InlineData("C10")]
|
||||
public async Task Initialize_rejects_timer_or_counter_tag_with_NotSupportedException(string address)
|
||||
{
|
||||
// A Timer/Counter address parses cleanly but the read path has no decode case for it,
|
||||
// so it must fail fast at init rather than throw a misleading type-mismatch on every
|
||||
// read. The host is reserved-for-documentation so the TCP connect can never succeed —
|
||||
// the unsupported-address guard runs before the connect, so the NotSupportedException
|
||||
// is what surfaces.
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Quirk", address, S7DataType.Int16)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-tc");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain(address);
|
||||
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Faulted, "an unsupported-address config error must Fault the driver");
|
||||
health.LastError.ShouldNotBeNull();
|
||||
// A Timer/Counter address typed anything but Float64 (Timer) / Int32 (Counter) cannot be decoded.
|
||||
// It is refused PER TAG at every gate; it used to throw at init and fault every tag on the PLC.
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(
|
||||
new S7TagDefinition("Quirk", address, S7DataType.Int16, Writable: false), address);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that initialize accepts DB and MIQ addresses without triggering the unsupported guard.</summary>
|
||||
@@ -102,19 +86,21 @@ public sealed class S7DriverCodeReviewFixTests
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_a_timer_tag_supplied_only_through_driverConfigJson()
|
||||
{
|
||||
// The Timer/Counter guard must run against the *re-parsed* config, not just the
|
||||
// constructor options — proves Driver.S7-001 and Driver.S7-011 compose correctly.
|
||||
// The shape gate must run against the *re-parsed* config, not just the constructor options —
|
||||
// proves the per-tag skip and Driver.S7-011 compose correctly.
|
||||
var ctorOpts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) };
|
||||
using var drv = new S7Driver(ctorOpts, "s7-cfg-tc");
|
||||
using var drv = new S7Driver(ctorOpts, "s7-cfg-tc", new ScriptedS7PlcFactory());
|
||||
|
||||
const string json = """
|
||||
{ "Host": "192.0.2.1", "TimeoutMs": 250,
|
||||
{ "Host": "192.0.2.1", "TimeoutMs": 250, "Probe": { "Enabled": false },
|
||||
"RawTags": [ { "RawPath": "TimerTag", "TagConfig": "{\"address\":\"T5\",\"dataType\":\"Int16\"}", "WriteIdempotent": false } ] }
|
||||
""";
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync(json, TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("T5");
|
||||
await drv.InitializeAsync(json, TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
var skipped = drv.GetSkippedTags().ShouldNotBeNull().Sample.ShouldHaveSingleItem();
|
||||
skipped.RawPath.ShouldBe("TimerTag");
|
||||
skipped.Reason.ShouldContain("T5");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reinitialize applies a changed driverConfigJson.</summary>
|
||||
|
||||
@@ -158,23 +158,9 @@ public sealed class S7DriverCodeReviewFixTests2
|
||||
[InlineData(S7DataType.DateTime)]
|
||||
public async Task Initialize_rejects_wide_type_at_non_byte_address_with_NotSupportedException(S7DataType dt)
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
// DB1.DBD0 is a DWord (non-byte) address — guard-(b) must fault on the address shape
|
||||
// for a wide type. (At a byte address like DB1.DBB0 the 8-byte numerics round-trip;
|
||||
// see S7ScalarBlockTests.)
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("X", "DB1.DBD0", dt)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, $"s7-wide-nonbyte-{dt}");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain(dt.ToString());
|
||||
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Faulted);
|
||||
// DB1.DBD0 is a DWord (non-byte) address — a wide type there would mis-frame its value. (At a
|
||||
// byte address like DB1.DBB0 the 8-byte numerics round-trip; see S7ScalarBlockTests.)
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("X", "DB1.DBD0", dt), dt.ToString(), "byte-addressed");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Initialize accepts implemented data types.</summary>
|
||||
|
||||
@@ -17,22 +17,11 @@ public sealed class S7DriverReadWriteTests
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_invalid_tag_address_and_fails_fast()
|
||||
{
|
||||
// Bad address at init time must throw; the alternative (deferring the parse to the
|
||||
// first read) would surface the config bug as BadInternalError on every subsequent
|
||||
// Read which is impossible for an operator to diagnose from the OPC UA client.
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1", // reserved — will never complete TCP handshake
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-bad-tag");
|
||||
|
||||
// Either the TCP connect fails first (Exception) or the parser fails (FormatException)
|
||||
// — both are acceptable since both are init-time fail-fast. What matters is that we
|
||||
// don't return a "healthy" driver with a latent bad tag.
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
// A bad address is refused PER TAG at every gate (mapper/editor, deploy gate, driver init):
|
||||
// it reads BadNodeIdUnknown while its neighbours keep working. It used to throw at init and
|
||||
// fault every tag on the PLC, with the actor retrying the same init every 10 s forever.
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(
|
||||
new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16), "does not parse");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadAsync without initialize throws InvalidOperationException.</summary>
|
||||
|
||||
@@ -101,80 +101,32 @@ public sealed class S7DriverScaffoldTests
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_wide_type_array_with_clear_message()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-wide-array");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("Batch64");
|
||||
ex.Message.ShouldContain("array", Case.Insensitive);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4), "Int64", "array");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a wide scalar with a non-byte address (Float64 at DB1.DBW0) is rejected with a byte-address message.</summary>
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_wide_scalar_with_non_byte_address()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-wide-nonbyte");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("WideWord");
|
||||
ex.Message.ShouldContain("byte", Case.Insensitive);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64), "Float64", "byte-addressed");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a Timer tag with the wrong DataType (Int32, not Float64) is rejected.</summary>
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_Timer_tag_with_wrong_data_type()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Int32)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-timer-bad");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("Timer5");
|
||||
ex.Message.ShouldContain("Float64");
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Timer5", "T5", S7DataType.Int32, Writable: false), "T5", "Float64");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a Counter tag with the wrong DataType (Float32, not Int32) is rejected.</summary>
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_Counter_tag_with_wrong_data_type()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Float32)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-counter-bad");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("Counter3");
|
||||
ex.Message.ShouldContain("Int32");
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Counter3", "C3", S7DataType.Float32, Writable: false), "C3", "Int32");
|
||||
}
|
||||
|
||||
// ── Phase 4d bundle-review fix — Writable Timer/Counter guard ───────────────────────
|
||||
@@ -188,42 +140,16 @@ public sealed class S7DriverScaffoldTests
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_Timer_tag_declared_Writable()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-timer-writable");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("Timer5");
|
||||
ex.Message.ShouldContain("Writable", Case.Insensitive);
|
||||
ex.Message.ShouldContain("read-only", Case.Insensitive);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true), "T5", "writable", "read-only");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a Counter tag declared Writable=true is rejected at init with a Writable message.</summary>
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_Counter_tag_declared_Writable()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-counter-writable");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("Counter3");
|
||||
ex.Message.ShouldContain("Writable", Case.Insensitive);
|
||||
ex.Message.ShouldContain("read-only", Case.Insensitive);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true), "C3", "writable", "read-only");
|
||||
}
|
||||
|
||||
// ── Driver.S7-015 / S7-016 — writable Bool array must fail fast at init ─────────────
|
||||
@@ -236,20 +162,8 @@ public sealed class S7DriverScaffoldTests
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_writable_bool_array_tag_with_NotSupportedException()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
RawTags = S7RawTags.Entries(new S7TagDefinition("ArrTag", "DB1.DBX0.0", S7DataType.Bool, Writable: true, ArrayCount: 4)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-writable-bool-arr");
|
||||
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("ArrTag");
|
||||
ex.Message.ShouldContain("Bool array", Case.Insensitive);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("ArrTag", "DB1.DBX0.0", S7DataType.Bool, Writable: true, ArrayCount: 4), "Bool array", "writable");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writable non-bool arrays pass the init guard — the failure
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R07:H3 / R07:U4 — the tag shapes the driver cannot serve are refused per tag at the mapper,
|
||||
/// the deploy gate and the driver's init (see <see cref="S7TagShapeRules"/>), instead of faulting
|
||||
/// the whole driver at init or being silently mis-read.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7TagShapeGateTests
|
||||
{
|
||||
/// <summary>A typo'd area/size letter is refused per tag (it used to throw FormatException at init).</summary>
|
||||
[Fact]
|
||||
public Task Unparseable_address_is_refused_per_tag()
|
||||
=> S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Typo", "DB1.DBZ0", S7DataType.Int16), "DB1.DBZ0", "does not parse");
|
||||
|
||||
/// <summary>
|
||||
/// R07:U4 — a Bool array reads bit 0 of each byte, so a non-zero start bit was silently ignored
|
||||
/// (<c>DB1.DBX0.3</c> returned bit 0 of bytes 0..7). It is refused at authoring now.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public Task Bool_array_starting_at_a_nonzero_bit_is_refused()
|
||||
=> S7TagSkipAssert.RefusedPerTagAsync(
|
||||
new S7TagDefinition("OffsetBits", "DB1.DBX0.3", S7DataType.Bool, Writable: false, ArrayCount: 8),
|
||||
"Bool array", "bit 3");
|
||||
|
||||
/// <summary>A narrow type at the wrong access width fails every read with a type mismatch; refused up front.</summary>
|
||||
/// <param name="address">The address.</param>
|
||||
/// <param name="dt">The declared type, which does not match the address's access width.</param>
|
||||
[Theory]
|
||||
[InlineData("DB1.DBW0", S7DataType.Int32)]
|
||||
[InlineData("DB1.DBD0", S7DataType.Int16)]
|
||||
[InlineData("DB1.DBB0", S7DataType.Bool)]
|
||||
[InlineData("M0.0", S7DataType.Byte)]
|
||||
[InlineData("MW0", S7DataType.Float32)]
|
||||
public Task Narrow_type_at_the_wrong_access_width_is_refused(string address, S7DataType dt)
|
||||
=> S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Mismatch", address, dt), "access width");
|
||||
|
||||
/// <summary>Positive control: the shapes the driver serves are all still accepted by the mapper and the gate.</summary>
|
||||
/// <param name="address">The address.</param>
|
||||
/// <param name="dt">The declared type.</param>
|
||||
/// <param name="arrayCount">The array count, or 0 for a scalar.</param>
|
||||
/// <param name="writable">Whether the tag is writable.</param>
|
||||
[Theory]
|
||||
[InlineData("DB1.DBX0.0", S7DataType.Bool, 8, false)]
|
||||
[InlineData("DB1.DBX50.3", S7DataType.Bool, 0, true)]
|
||||
[InlineData("DB1.DBB0", S7DataType.Byte, 4, true)]
|
||||
[InlineData("DB1.DBW0", S7DataType.Int16, 0, true)]
|
||||
[InlineData("MW2", S7DataType.UInt16, 3, true)]
|
||||
[InlineData("DB1.DBD0", S7DataType.Float32, 0, true)]
|
||||
[InlineData("QD4", S7DataType.Int32, 2, true)]
|
||||
[InlineData("DB1.DBB0", S7DataType.Float64, 0, true)]
|
||||
[InlineData("DB1.DBB0", S7DataType.String, 0, true)]
|
||||
[InlineData("T5", S7DataType.Float64, 0, false)]
|
||||
[InlineData("C3", S7DataType.Int32, 0, false)]
|
||||
public void Servable_shapes_are_accepted(string address, S7DataType dt, int arrayCount, bool writable)
|
||||
{
|
||||
var def = new S7TagDefinition("Ok", address, dt, Writable: writable, ArrayCount: arrayCount == 0 ? null : arrayCount);
|
||||
var blob = S7TagDefinitionFactory.ToTagConfig(def);
|
||||
S7TagDefinitionFactory.FromTagConfig(blob, "Ok", out var mapped, out var reason).ShouldBeTrue(reason);
|
||||
mapped.Address.ShouldBe(address);
|
||||
S7TagDefinitionFactory.Inspect(blob).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared assertion for an S7 tag shape the driver cannot serve. It must be refused PER TAG at
|
||||
/// every gate, never fault the whole driver: the mapper (and so the AdminUI editor) refuses it with
|
||||
/// the reason, the deploy gate (<see cref="S7TagDefinitionFactory.Inspect"/>) warns, and a driver
|
||||
/// carrying it next to a good tag initializes Healthy, reports the bad one through
|
||||
/// <see cref="ISkippedTagReporter"/>, and still serves the good one (the positive control).
|
||||
/// </summary>
|
||||
internal static class S7TagSkipAssert
|
||||
{
|
||||
private const string GoodName = "Good";
|
||||
|
||||
/// <summary>Asserts <paramref name="bad"/> is refused per tag at the mapper, the deploy gate and the driver.</summary>
|
||||
/// <param name="bad">The unservable tag.</param>
|
||||
/// <param name="reasonFragments">Fragments the operator-facing rejection reason must contain.</param>
|
||||
/// <returns>A task that completes when every gate has been checked.</returns>
|
||||
public static async Task RefusedPerTagAsync(S7TagDefinition bad, params string[] reasonFragments)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var blob = S7TagDefinitionFactory.ToTagConfig(bad);
|
||||
|
||||
// Mapper (and therefore the AdminUI editor, which asks it) — refused, with the reason.
|
||||
S7TagDefinitionFactory.FromTagConfig(blob, bad.Name, out _, out var reason).ShouldBeFalse();
|
||||
reason.ShouldNotBeNull();
|
||||
foreach (var f in reasonFragments) reason.ShouldContain(f, Case.Insensitive);
|
||||
|
||||
// Deploy gate — warns with the same reason.
|
||||
var warnings = S7TagDefinitionFactory.Inspect(blob);
|
||||
warnings.ShouldHaveSingleItem().ShouldContain(reason);
|
||||
|
||||
// Driver — initializes Healthy with the bad tag skipped and the good tag served.
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(bad, new S7TagDefinition(GoodName, "DB1.DBW0", S7DataType.UInt16, Writable: false)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-skip-" + bad.Name, new ScriptedS7PlcFactory());
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "one unservable tag must not fault the driver");
|
||||
var skipped = drv.GetSkippedTags().ShouldNotBeNull();
|
||||
skipped.Sample.ShouldHaveSingleItem().RawPath.ShouldBe(bad.Name);
|
||||
skipped.Sample[0].Reason.ShouldBe(reason);
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, ct);
|
||||
builder.Variables.Select(v => v.Name).ShouldBe([GoodName]);
|
||||
|
||||
var results = await drv.ReadAsync([bad.Name, GoodName], ct);
|
||||
results[0].StatusCode.ShouldBe(0x80340000u, "the skipped tag reads BadNodeIdUnknown");
|
||||
results[1].StatusCode.ShouldBe(0u, "the good neighbour is still served");
|
||||
}
|
||||
}
|
||||
@@ -318,16 +318,9 @@ public sealed class S7ValueCodecTests
|
||||
[Fact]
|
||||
public async Task New_types_declared_as_arrays_or_off_byte_addresses_fail_init()
|
||||
{
|
||||
S7DriverOptions With(S7TagDefinition t) => new()
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(t),
|
||||
};
|
||||
using var arr = new S7Driver(With(Tag(S7DataType.Dtl) with { ArrayCount = 2 }), "a", new ConnectingFakeS7PlcFactory());
|
||||
await Should.ThrowAsync<NotSupportedException>(() => arr.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
using var word = new S7Driver(With(Tag(S7DataType.Tod, address: "DB1.DBW0")), "b", new ConnectingFakeS7PlcFactory());
|
||||
await Should.ThrowAsync<NotSupportedException>(() => word.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
// Refused PER TAG at every gate (they used to throw at init and fault the whole driver).
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(Tag(S7DataType.Dtl) with { Name = "DtlArr", ArrayCount = 2 }, "Dtl", "array");
|
||||
await S7TagSkipAssert.RefusedPerTagAsync(Tag(S7DataType.Tod, address: "DB1.DBW0") with { Name = "TodWord" }, "Tod", "byte-addressed");
|
||||
}
|
||||
|
||||
// ---- TagConfig ----
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using S7DataItem = global::S7.Net.Types.DataItem;
|
||||
using S7NetDataType = global::S7.Net.DataType;
|
||||
using VarType = global::S7.Net.VarType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A scriptable <see cref="IS7PlcFactory"/>: every connection it creates delegates its wire
|
||||
/// operations to the factory's handlers, so a test scripts the device once and observes every
|
||||
/// connection (including reopened ones). Counts wire operations so a test can prove what did
|
||||
/// NOT go to the wire without timing anything.
|
||||
/// </summary>
|
||||
internal sealed class ScriptedS7PlcFactory : IS7PlcFactory
|
||||
{
|
||||
private int _wireOps;
|
||||
|
||||
/// <summary>Every connection created, in order.</summary>
|
||||
public List<ScriptedS7Plc> Created { get; } = [];
|
||||
|
||||
/// <summary>Wire operations issued (reads, multi-var reads, writes; not opens or status probes).</summary>
|
||||
public int WireOps => Volatile.Read(ref _wireOps);
|
||||
|
||||
/// <summary>Hook run inside every <see cref="IS7Plc.OpenAsync"/>; the default opens immediately.</summary>
|
||||
public Func<int, CancellationToken, Task> Open { get; set; } = (_, _) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Per-address string read; the default returns <c>(ushort)42</c>.</summary>
|
||||
public Func<string, CancellationToken, Task<object?>> Read { get; set; } = (_, _) => Task.FromResult<object?>((ushort)42);
|
||||
|
||||
/// <summary>Block read; the default returns <c>count</c> zero bytes.</summary>
|
||||
public Func<S7NetDataType, int, int, int, CancellationToken, Task<byte[]>> ReadBytes { get; set; }
|
||||
= (_, _, _, count, _) => Task.FromResult(new byte[count]);
|
||||
|
||||
/// <summary>Multi-var read; the default answers every item with a zero of its var type.</summary>
|
||||
public Func<List<S7DataItem>, CancellationToken, Task<IReadOnlyList<S7DataItem>>> ReadMulti { get; set; }
|
||||
= (items, _) =>
|
||||
{
|
||||
foreach (var i in items)
|
||||
i.Value = i.VarType switch
|
||||
{
|
||||
VarType.Bit => false,
|
||||
VarType.Byte => (byte)0,
|
||||
VarType.Word => (ushort)0,
|
||||
_ => (uint)0,
|
||||
};
|
||||
return Task.FromResult<IReadOnlyList<S7DataItem>>(items);
|
||||
};
|
||||
|
||||
/// <summary>Per-address string write; the default succeeds.</summary>
|
||||
public Func<string, object, CancellationToken, Task> Write { get; set; } = (_, _, _) => Task.CompletedTask;
|
||||
|
||||
/// <summary>Block write; the default succeeds.</summary>
|
||||
public Func<S7NetDataType, int, int, byte[], CancellationToken, Task> WriteBytes { get; set; }
|
||||
= (_, _, _, _, _) => Task.CompletedTask;
|
||||
|
||||
/// <summary>CPU-status probe; the default answers.</summary>
|
||||
public Func<CancellationToken, Task> ReadStatus { get; set; } = _ => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
|
||||
{
|
||||
lock (Created)
|
||||
{
|
||||
var plc = new ScriptedS7Plc(this, Created.Count);
|
||||
Created.Add(plc);
|
||||
return plc;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Counts one wire operation.</summary>
|
||||
internal void CountWireOp() => Interlocked.Increment(ref _wireOps);
|
||||
}
|
||||
|
||||
/// <summary>One connection created by <see cref="ScriptedS7PlcFactory"/>.</summary>
|
||||
internal sealed class ScriptedS7Plc(ScriptedS7PlcFactory factory, int index) : IS7Plc
|
||||
{
|
||||
/// <summary>Zero-based creation index.</summary>
|
||||
public int Index { get; } = index;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
/// <summary>True once disposed.</summary>
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public int MaxPduSize => 240;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task OpenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await factory.Open(Index, cancellationToken).ConfigureAwait(false);
|
||||
IsConnected = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<object?> ReadAsync(string address, CancellationToken cancellationToken)
|
||||
{
|
||||
factory.CountWireOp();
|
||||
return factory.Read(address, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
factory.CountWireOp();
|
||||
return factory.ReadBytes(area, db, startByteAdr, count, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<S7DataItem>> ReadMultipleVarsAsync(List<S7DataItem> items, CancellationToken cancellationToken)
|
||||
{
|
||||
factory.CountWireOp();
|
||||
return factory.ReadMulti(items, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(string address, object value, CancellationToken cancellationToken)
|
||||
{
|
||||
factory.CountWireOp();
|
||||
return factory.Write(address, value, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken)
|
||||
{
|
||||
factory.CountWireOp();
|
||||
return factory.WriteBytes(area, db, startByteAdr, value, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ReadStatusAsync(CancellationToken cancellationToken) => factory.ReadStatus(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Close() => IsConnected = false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
IsConnected = false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user