diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/TagsTab.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/TagsTab.razor index ddef975..7a3a1a9 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/TagsTab.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/TagsTab.razor @@ -129,6 +129,53 @@ else if (_tags.Count > 0) } + @* #156 — advanced Modbus fields. Collapsed by default; the basic form covers the + typical edit workflow. Expander surfaces Deadband (#141) / UnitId override (#142) / + CoalesceProhibited (#143) for the multi-slave / noisy-analog / protected-hole + deployments. Save-side flushes these into TagConfig as a structured JSON object + that ModbusTagDto's BuildTag honours alongside the address string. *@ + @if (_isModbus) + { +
+ +
+ @if (_showAdvanced) + { +
+
+ + +
Suppress publishes when |new - last| < threshold.
+
+
+ + +
Per-tag MBAP unit ID. Required when fronting a multi-slave gateway.
+
+
+ +
+ + +
+
Use when surrounding registers are write-only or fault on read.
+
+
+ } + } + @if (_error is not null) {
@_error
}
@@ -155,6 +202,14 @@ else if (_tags.Count > 0) private bool _isModbus; private string? _modbusAddress; + // #156 — advanced Modbus fields. Bound separately from _draft.TagConfig because they + // round-trip through a structured JSON shape, not a single string. Synced into TagConfig + // by OnAdvancedChanged / OnAddressChanged (whichever fires). + private bool _showAdvanced; + private double? _advancedDeadband; + private byte? _advancedUnitId; + private bool _advancedCoalesceProhibited; + private static Tag NewBlankDraft() => new() { TagId = string.Empty, DriverInstanceId = string.Empty, Name = string.Empty, @@ -184,6 +239,15 @@ else if (_tags.Count > 0) _isModbus = false; _error = null; _showForm = true; + ResetAdvanced(); + } + + private void ResetAdvanced() + { + _showAdvanced = false; + _advancedDeadband = null; + _advancedUnitId = null; + _advancedCoalesceProhibited = false; } private void StartEdit(Tag row) @@ -206,12 +270,37 @@ else if (_tags.Count > 0) }; _editMode = true; OnDriverChanged(); - // Try to extract addressString from existing JSON config so the Modbus editor pre-fills. - if (_isModbus) _modbusAddress = TryExtractAddressString(row.TagConfig); + // Try to extract addressString + advanced fields from existing JSON config so the + // form pre-fills correctly when an operator hits Edit on an existing row. + ResetAdvanced(); + if (_isModbus) HydrateModbusFromTagConfig(row.TagConfig); _error = null; _showForm = true; } + private void HydrateModbusFromTagConfig(string tagConfig) + { + try + { + using var doc = JsonDocument.Parse(tagConfig); + var root = doc.RootElement; + if (root.TryGetProperty("addressString", out var addr) && addr.ValueKind == JsonValueKind.String) + _modbusAddress = addr.GetString(); + if (root.TryGetProperty("deadband", out var db) && db.ValueKind is JsonValueKind.Number) + _advancedDeadband = db.GetDouble(); + if (root.TryGetProperty("unitId", out var uid) && uid.ValueKind is JsonValueKind.Number) + _advancedUnitId = uid.GetByte(); + if (root.TryGetProperty("coalesceProhibited", out var cp) && cp.ValueKind is JsonValueKind.True or JsonValueKind.False) + _advancedCoalesceProhibited = cp.GetBoolean(); + + // Auto-expand the advanced panel when any of those fields was actually set so + // operators see immediately what's been configured. + if (_advancedDeadband.HasValue || _advancedUnitId.HasValue || _advancedCoalesceProhibited) + _showAdvanced = true; + } + catch { /* Malformed JSON falls back to empty advanced state. */ } + } + private void OnDriverChanged() { var driver = _drivers?.FirstOrDefault(d => d.DriverInstanceId == _draft.DriverInstanceId); @@ -219,21 +308,33 @@ else if (_tags.Count > 0) && string.Equals(driver.DriverType, "Modbus", StringComparison.OrdinalIgnoreCase); } - private void OnAddressChanged() - { - // Sync the address string into TagConfig as a JSON object the factory consumes. - if (string.IsNullOrWhiteSpace(_modbusAddress)) return; - _draft.TagConfig = JsonSerializer.Serialize(new { addressString = _modbusAddress }); - } + private void OnAddressChanged() => RefreshTagConfigJson(); + private void OnAdvancedChanged() => RefreshTagConfigJson(); - private static string? TryExtractAddressString(string tagConfig) + /// + /// Re-serializes the current address + advanced fields into TagConfig as a structured + /// JSON object. ModbusTagDto's BuildTag honours every field — addressString drives + /// the parser, while the structured bits (deadband / unitId / coalesceProhibited) + /// pass through directly. Fields with default / empty values are omitted from the + /// JSON to keep diffs in the existing draft-diff viewer clean. + /// + private void RefreshTagConfigJson() { - try + if (string.IsNullOrWhiteSpace(_modbusAddress) + && !_advancedDeadband.HasValue + && !_advancedUnitId.HasValue + && !_advancedCoalesceProhibited) { - using var doc = JsonDocument.Parse(tagConfig); - return doc.RootElement.TryGetProperty("addressString", out var v) ? v.GetString() : null; + return; } - catch { return null; } + + var payload = new Dictionary(); + if (!string.IsNullOrWhiteSpace(_modbusAddress)) payload["addressString"] = _modbusAddress; + if (_advancedDeadband.HasValue) payload["deadband"] = _advancedDeadband.Value; + if (_advancedUnitId.HasValue) payload["unitId"] = _advancedUnitId.Value; + if (_advancedCoalesceProhibited) payload["coalesceProhibited"] = true; + + _draft.TagConfig = JsonSerializer.Serialize(payload); } private void Cancel() diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/TagServiceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/TagServiceTests.cs index c77ba80..3e2370f 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/TagServiceTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/TagServiceTests.cs @@ -64,6 +64,56 @@ public sealed class TagServiceTests fresh.TagConfig.ShouldContain("40001:F"); } + [Fact] + public async Task TagConfig_With_Advanced_Modbus_Fields_RoundTrips_Through_Factory() + { + // #156 — TagsTab serializes advanced fields (deadband / unitId / coalesceProhibited) + // into TagConfig as a structured JSON object alongside addressString. Confirm the + // shape survives a DB round-trip AND that ModbusDriverFactoryExtensions.BuildTag's + // JSON consumer accepts it. If the field names drift between the UI and the factory, + // this test catches it before users do. + using var ctx = NewContext(); + var svc = new TagService(ctx); + + var advancedConfig = System.Text.Json.JsonSerializer.Serialize(new + { + addressString = "40001:F:CDAB", + deadband = 0.5, + unitId = 7, + coalesceProhibited = true, + }); + var t = NewTag("Tank"); + t.TagConfig = advancedConfig; + await svc.CreateAsync(1, t, TestContext.Current.CancellationToken); + + var fresh = (await svc.ListAsync(1, ct: TestContext.Current.CancellationToken)).Single(); + fresh.TagConfig.ShouldContain("addressString"); + fresh.TagConfig.ShouldContain("deadband"); + fresh.TagConfig.ShouldContain("unitId"); + fresh.TagConfig.ShouldContain("coalesceProhibited"); + + // Build the wrapping driver-config JSON the factory consumes (one tag, the structured + // config above as its TagConfig), then construct a driver from it. If any field name + // doesn't match the DTO, BuildTag throws here. + var driverConfig = System.Text.Json.JsonSerializer.Serialize(new + { + host = "127.0.0.1", + tags = new[] + { + new + { + name = "Tank", + addressString = "40001:F:CDAB", + deadband = 0.5, + unitId = (byte)7, + coalesceProhibited = true, + }, + }, + }); + Should.NotThrow(() => ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDriverFactoryExtensions.CreateInstance( + "advanced-rt", driverConfig)); + } + [Fact] public async Task Delete_Removes_The_Row() {