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()
{