> read, CancellationToken ct)
+ {
+ try
+ {
+ await read(ct).ConfigureAwait(false);
+ return null;
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (PlcException pex) when (S7Driver.IsAccessDenied(pex))
+ {
+ return new(false,
+ "S7 connected but data access is denied — enable PUT/GET communication in TIA Portal " +
+ $"(Protection & Security) for this CPU. PLC reported: {pex.Message}", null);
+ }
+ catch (PlcException pex) when (pex.ErrorCode == ErrorCode.ReadData)
+ {
+ return null; // address not available on this CPU — not a connection problem
+ }
+ catch (Exception ex)
+ {
+ return new(false, $"S7 connected but a test read failed: {ex.Message}", null);
+ }
+ }
+
private static (string host, int port) ExtractTarget(S7DriverOptions opts)
=> (opts.Host, opts.Port);
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ValueCodecs.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ValueCodecs.cs
index 9b4a23a6..8017987b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ValueCodecs.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ValueCodecs.cs
@@ -287,6 +287,8 @@ internal static class S7ValueCodecs
}
}
+ // Fractional input is rounded (Convert.ToInt64, banker's), matching the driver's existing Int32 write
+ // path (BoxValueForWrite → Convert.ToInt32). "Refused, not rounded" applies to S5TIME's time-base grid.
private static long ToMilliseconds(object? value, string type)
{
try { return value is TimeSpan ts ? (long)ts.TotalMilliseconds : Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture); }
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor
index 830903a4..b68cf28d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/S7DriverForm.razor
@@ -56,6 +56,10 @@
Optional hex override, e.g. 03.01. Not combinable with Connection type.
+ @if (_form.TsapConflictError() is { } tsapError)
+ {
+ @tsapError
+ }
@@ -183,6 +187,12 @@
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
+ /// The same error the driver factory raises for Remote TSAP + Connection type together; null when fine.
+ public string? TsapConflictError() =>
+ !string.IsNullOrWhiteSpace(RemoteTsap) && !string.IsNullOrWhiteSpace(ConnectionTypeText)
+ ? "S7 driver config " + S7DriverOptions.TsapConflictDetail
+ : null;
+
public static FormModel FromOptions(S7DriverOptions o) => new()
{
Host = o.Host,
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TsapAndPduTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TsapAndPduTests.cs
index 956ba2b1..f1dd5dde 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TsapAndPduTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TsapAndPduTests.cs
@@ -188,4 +188,46 @@ public sealed class S7TsapAndPduTests
public void Dispose() => IsConnected = false;
}
}
+
+ [Fact]
+ public void Factory_conflict_message_matches_the_shared_detail()
+ {
+ var ex = Should.Throw(() => S7DriverFactoryExtensions.ParseOptions("d",
+ """{"host":"h","remoteTsap":"03.01","connectionType":"OP"}"""));
+ ex.Message.ShouldContain(S7DriverOptions.TsapConflictDetail);
+ }
+
+ // ---- Test Connect data-access check (PUT/GET) ----
+
+ [Fact]
+ public async Task Probe_data_access_succeeds_when_the_read_works() =>
+ (await S7DriverProbe.CheckDataAccessAsync(_ => Task.FromResult(new byte[2]), TestContext.Current.CancellationToken))
+ .ShouldBeNull();
+
+ [Fact]
+ public async Task Probe_fails_with_actionable_message_when_put_get_is_disabled()
+ {
+ var denied = new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData,
+ new Exception("Accessing object not allowed"));
+ var r = await S7DriverProbe.CheckDataAccessAsync(_ => throw denied, TestContext.Current.CancellationToken);
+ r.ShouldNotBeNull().Ok.ShouldBeFalse();
+ r.Message!.ShouldContain("PUT/GET");
+ }
+
+ [Fact]
+ public async Task Probe_tolerates_an_address_not_available_read_error()
+ {
+ var missing = new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData, new Exception("Object does not exist"));
+ (await S7DriverProbe.CheckDataAccessAsync(_ => throw missing, TestContext.Current.CancellationToken)).ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task Probe_reports_any_other_read_fault_as_a_failure()
+ {
+ var r = await S7DriverProbe.CheckDataAccessAsync(
+ _ => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ConnectionError, "socket dropped"),
+ TestContext.Current.CancellationToken);
+ r.ShouldNotBeNull().Ok.ShouldBeFalse();
+ r.Message!.ShouldContain("socket dropped");
+ }
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ValueCodecTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ValueCodecTests.cs
index 2d182d34..bb285f52 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ValueCodecTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ValueCodecTests.cs
@@ -221,6 +221,16 @@ public sealed class S7ValueCodecTests
Encode(S7DataType.Tod, TimeSpan.FromHours(1)).ShouldBe([0, 0x36, 0xEE, 0x80]);
}
+ [Fact]
+ public void Fractional_millisecond_double_rounds_like_the_other_int32_writes()
+ {
+ // Pinned: matches BoxValueForWrite's Convert.ToInt32 behaviour (banker's), see docs.
+ Encode(S7DataType.Tod, 100.7).ShouldBe(Encode(S7DataType.Tod, 101));
+ Encode(S7DataType.Time, 100.7).ShouldBe(Encode(S7DataType.Time, 101));
+ Encode(S7DataType.S5Time, 99.7).ShouldBe(Encode(S7DataType.S5Time, 100));
+ Should.Throw(() => Encode(S7DataType.S5Time, 100.7)); // rounds to 101: off the grid
+ }
+
// ---- TIME ----
[Theory]
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs
index 09a98e61..e4ab368a 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs
@@ -138,4 +138,28 @@ public sealed class S7DriverPageFormSerializationTests
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter() } });
json.ShouldContain("\"connectionType\":\"PG\"");
}
+
+ [Fact]
+ public void Numeric_tsap_loads_into_the_form_model_instead_of_falling_back_to_defaults()
+ {
+ // The factory accepts a JSON number; the form binds the same document the same way, so a Save
+ // cannot silently replace the authored config with defaults.
+ var opts = JsonSerializer.Deserialize(
+ """{"remoteTsap":769,"localTsap":"01.00","cpuType":"S7300"}""",
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter() } });
+ opts.ShouldNotBeNull();
+ var back = S7DriverForm.FormModel.FromOptions(opts).ToOptions();
+ back.RemoteTsap.ShouldBe("03.01");
+ back.LocalTsap.ShouldBe("01.00");
+ back.CpuType.ShouldBe(S7CpuType.S7300);
+ }
+
+ [Fact]
+ public void Form_reports_the_factory_conflict_error_for_remote_tsap_plus_connection_type()
+ {
+ var form = new S7DriverForm.FormModel { RemoteTsap = "03.01", ConnectionTypeText = "PG" };
+ form.TsapConflictError().ShouldNotBeNull().ShouldContain(S7DriverOptions.TsapConflictDetail);
+ new S7DriverForm.FormModel { RemoteTsap = "03.01" }.TsapConflictError().ShouldBeNull();
+ new S7DriverForm.FormModel { ConnectionTypeText = "PG" }.TsapConflictError().ShouldBeNull();
+ }
}