diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor new file mode 100644 index 00000000..0b32409d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor @@ -0,0 +1,351 @@ +@page "/clusters/{ClusterId}/drivers/new/s7" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Driver.S7 +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New Siemens S7 driver" : "Edit Siemens S7 driver") · @ClusterId

+ Cancel +
+ + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Driver instance @DriverInstanceId was not found in cluster @ClusterId. +
+} +else +{ + + + + + + + @* Connection *@ +
+
Connection
+
+
+
+ + +
PLC IP address or hostname.
+
+
+ + +
ISO-on-TCP; usually 102.
+
+
+ + +
+
+
+
+ + + @foreach (var v in Enum.GetValues()) + { + + } + +
Controls ISO-TSAP slot byte during handshake.
+
+
+ + +
Almost always 0.
+
+
+ + +
S7-300/400 = 2; S7-1200/1500 = 0.
+
+
+
+
+ + @* Probe *@ +
+
Connectivity probe
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
Test Connect timeout (1–60 s).
+
+
+
+
+ + @* Tags — read-only JSON view *@ +
+
Tags
+
+
+ Tag list editor coming in a follow-up phase. To add/remove tags, edit the JSON directly in the raw driver config via the generic editor, or deploy via the import tooling. +
+ @if (_form.TagsJson is not null) + { +
@_form.TagsJson
+ } + else + { +

No tags configured.

+ } +
+
+ + +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? DriverInstanceId { get; set; } + + private const string DriverTypeKey = "S7"; + + private bool IsNew => string.IsNullOrEmpty(DriverInstanceId); + + private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new() + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + }; + + private FormModel _form = new(); + private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey }; + private DriverInstance? _existing; + private List _namespaces = new(); + private bool _loaded, _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _namespaces = await db.Namespaces.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .OrderBy(n => n.NamespaceId).ToListAsync(); + + if (IsNew) + { + _identityModel = new() { DriverType = DriverTypeKey, NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "", Enabled = true }; + _form = FormModel.FromOptions(new S7DriverOptions()); + } + else + { + _existing = await db.DriverInstances.AsNoTracking() + .FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (_existing is not null) + { + _identityModel = new() + { + DriverInstanceId = _existing.DriverInstanceId, + Name = _existing.Name, + DriverType = _existing.DriverType, + NamespaceId = _existing.NamespaceId, + Enabled = _existing.Enabled, + }; + var opts = TryDeserialize(_existing.DriverConfig) ?? new S7DriverOptions(); + _form = FormModel.FromOptions(opts); + _form.ResilienceConfig = _existing.ResilienceConfig; + _form.RowVersion = _existing.RowVersion; + } + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + var opts = _form.ToOptions(); + var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts); + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId)) + { + _error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return; + } + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = _identityModel.DriverInstanceId, + ClusterId = ClusterId, + NamespaceId = _identityModel.NamespaceId, + Name = _identityModel.Name, + DriverType = DriverTypeKey, + Enabled = _identityModel.Enabled, + DriverConfig = configJson, + ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig, + }); + } + else + { + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.NamespaceId = _identityModel.NamespaceId; + entity.Name = _identityModel.Name; + entity.Enabled = _identityModel.Enabled; + entity.DriverConfig = configJson; + entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes."; + } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.DriverInstances.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were viewing it. Reload before deleting."; + } + catch (Exception ex) + { + _error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)"; + } + finally { _busy = false; } + } + + private static S7DriverOptions? TryDeserialize(string json) + { + try { return System.Text.Json.JsonSerializer.Deserialize(json, _jsonOpts); } + catch { return null; } + } + + public sealed class FormModel + { + // Connection + public string Host { get; set; } = "127.0.0.1"; + public int Port { get; set; } = 102; + public S7CpuType CpuType { get; set; } = S7CpuType.S71500; + public short Rack { get; set; } = 0; + public short Slot { get; set; } = 0; + public int TimeoutSeconds { get; set; } = 5; + + // Probe + public bool ProbeEnabled { get; set; } = true; + public int ProbeIntervalSeconds { get; set; } = 5; + public int ProbeTimeoutSeconds { get; set; } = 2; + public int AdminProbeTimeoutSeconds { get; set; } = 5; + + // Tags JSON view (read-only) + public string? TagsJson { get; set; } + + // Common + public string? ResilienceConfig { get; set; } + public byte[] RowVersion { get; set; } = []; + + public static FormModel FromOptions(S7DriverOptions o) + { + string? tagsJson = o.Tags.Count == 0 ? null + : System.Text.Json.JsonSerializer.Serialize(o.Tags, + new System.Text.Json.JsonSerializerOptions + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + WriteIndented = true, + }); + return new FormModel + { + Host = o.Host, + Port = o.Port, + CpuType = o.CpuType, + Rack = o.Rack, + Slot = o.Slot, + TimeoutSeconds = (int)o.Timeout.TotalSeconds, + ProbeEnabled = o.Probe.Enabled, + ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, + ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, + AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds, + TagsJson = tagsJson, + }; + } + + public S7DriverOptions ToOptions() => new() + { + Host = Host, + Port = Port, + CpuType = CpuType, + Rack = Rack, + Slot = Slot, + Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + Probe = new S7ProbeOptions + { + Enabled = ProbeEnabled, + Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds), + Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds), + }, + ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, + // Tags preserved from original JSON; this form does not edit the tag list. + Tags = [], + }; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs new file mode 100644 index 00000000..9c48be8d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/S7DriverPageFormSerializationTests.cs @@ -0,0 +1,104 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.S7; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +public sealed class S7DriverPageFormSerializationTests +{ + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + [Fact] + public void RoundTrip_PreservesKnownFields() + { + var original = new S7DriverOptions + { + Host = "10.0.0.5", + Port = 102, + CpuType = S7CpuType.S71200, + Rack = 0, + Slot = 1, + Timeout = TimeSpan.FromSeconds(10), + Probe = new S7ProbeOptions + { + Enabled = false, + Interval = TimeSpan.FromSeconds(15), + Timeout = TimeSpan.FromSeconds(3), + }, + ProbeTimeoutSeconds = 30, + Tags = [], + }; + + var json = JsonSerializer.Serialize(original, _opts); + var back = JsonSerializer.Deserialize(json, _opts); + + back.ShouldNotBeNull(); + back.Host.ShouldBe("10.0.0.5"); + back.Port.ShouldBe(102); + back.CpuType.ShouldBe(S7CpuType.S71200); + back.Rack.ShouldBe((short)0); + back.Slot.ShouldBe((short)1); + back.Timeout.ShouldBe(TimeSpan.FromSeconds(10)); + back.Probe.ShouldNotBeNull(); + back.Probe.Enabled.ShouldBeFalse(); + back.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(15)); + back.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(3)); + back.ProbeTimeoutSeconds.ShouldBe(30); + back.Tags.ShouldBeEmpty(); + } + + [Fact] + public void Deserialize_DropsUnknownFields() + { + var jsonWithExtra = """{"unknownField":"old-value","probeTimeoutSeconds":12}"""; + var optsSkip = new JsonSerializerOptions(_opts) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + }; + var back = JsonSerializer.Deserialize(jsonWithExtra, optsSkip); + back.ShouldNotBeNull(); + back.ProbeTimeoutSeconds.ShouldBe(12); + } + + [Fact] + public void FormModel_RoundTrip_PreservesEditableFields() + { + var opts = new S7DriverOptions + { + Host = "192.168.1.50", + Port = 102, + CpuType = S7CpuType.S7300, + Rack = 0, + Slot = 2, + Timeout = TimeSpan.FromSeconds(7), + Probe = new S7ProbeOptions + { + Enabled = true, + Interval = TimeSpan.FromSeconds(8), + Timeout = TimeSpan.FromSeconds(4), + }, + ProbeTimeoutSeconds = 20, + }; + + var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers + .S7DriverPage.FormModel.FromOptions(opts); + var roundTripped = form.ToOptions(); + + roundTripped.Host.ShouldBe("192.168.1.50"); + roundTripped.Port.ShouldBe(102); + roundTripped.CpuType.ShouldBe(S7CpuType.S7300); + roundTripped.Rack.ShouldBe((short)0); + roundTripped.Slot.ShouldBe((short)2); + roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(7)); + roundTripped.Probe.Enabled.ShouldBeTrue(); + roundTripped.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(8)); + roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(4)); + roundTripped.ProbeTimeoutSeconds.ShouldBe(20); + } +}