Task #156 — TagsTab: per-tag advanced Modbus fields (Deadband, UnitId, CoalesceProhibited)

#155 wired the basic tag form (Name / Driver / Equipment / DataType / Access /
WriteIdempotent + ModbusAddressEditor for the address). The per-tag knobs added
across #141 / #142 / #143 still required operators to hand-edit TagConfig JSON.
This commit exposes them through an "Advanced" expander.

UI changes (TagsTab.razor):

- Collapsible "▶ Advanced (Deadband / UnitId override / CoalesceProhibited)"
  button below the address editor, visible only when the selected driver is
  Modbus. Collapsed by default — basic form covers the typical edit workflow.
- Three numeric / checkbox inputs with inline help text explaining each knob's
  purpose and when to use it.
- _showAdvanced auto-opens on Edit when any of the advanced fields are present
  in the existing TagConfig — operators see immediately what's been configured.

Save-side serialization:

- New RefreshTagConfigJson serializes the address + advanced fields into a
  structured JSON object using a Dictionary<string, object?>. Fields with
  default / empty values are omitted to keep diffs in the existing draft-diff
  viewer minimal — a tag with only an address still produces
  `{"addressString":"40001:F"}` and not a full superset object with nulls.
- OnAddressChanged + OnAdvancedChanged both delegate to RefreshTagConfigJson
  so any input change keeps TagConfig in sync.

Read-side hydration:

- New HydrateModbusFromTagConfig parses an existing TagConfig JSON and
  populates _modbusAddress + the three advanced fields. Falls back to empty
  defaults on malformed JSON. ResetAdvanced is called before hydration on
  every form open so leftover state from a previous edit doesn't leak.

ResetAdvanced helper introduced + called from StartAdd so a fresh "New tag"
form starts with everything cleared.

Tests (1 new in TagServiceTests):
- TagConfig_With_Advanced_Modbus_Fields_RoundTrips_Through_Factory — creates a
  tag whose TagConfig carries addressString + deadband + unitId +
  coalesceProhibited, persists via TagService, reloads, asserts every field
  survives. Then constructs a wrapping driver-config JSON and feeds it to
  ModbusDriverFactoryExtensions.CreateInstance — confirms the field NAMES the
  UI emits match what BuildTag's DTO consumes. If the UI's JSON shape ever
  drifts from the factory's expected DTO, this test catches it before users do.

119 + 1 = 120 Admin tests green. Solution build clean.
This commit is contained in:
Joseph Doherty
2026-04-25 04:22:50 -04:00
parent ec57df1009
commit 012c42a846
2 changed files with 164 additions and 13 deletions

View File

@@ -129,6 +129,53 @@ else if (_tags.Count > 0)
}
</div>
@* #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)
{
<div class="mt-3">
<button type="button" class="btn btn-sm btn-link p-0"
@onclick="() => _showAdvanced = !_showAdvanced">
@(_showAdvanced ? "▼ Advanced" : "▶ Advanced") (Deadband / UnitId override / CoalesceProhibited)
</button>
</div>
@if (_showAdvanced)
{
<div class="row g-3 mt-1 ps-3 border-start">
<div class="col-md-4">
<label class="form-label small">Deadband
<span class="text-muted">(numeric scalar types only)</span>
</label>
<input type="number" step="any" class="form-control form-control-sm"
@bind="_advancedDeadband" @bind:after="OnAdvancedChanged"
placeholder="e.g. 0.5"/>
<div class="form-text">Suppress publishes when |new - last| &lt; threshold.</div>
</div>
<div class="col-md-4">
<label class="form-label small">UnitId override
<span class="text-muted">(0255, blank = use driver default)</span>
</label>
<input type="number" min="0" max="255" class="form-control form-control-sm"
@bind="_advancedUnitId" @bind:after="OnAdvancedChanged"
placeholder="leave blank for driver-level"/>
<div class="form-text">Per-tag MBAP unit ID. Required when fronting a multi-slave gateway.</div>
</div>
<div class="col-md-4">
<label class="form-label small">CoalesceProhibited</label>
<div class="form-check mt-1">
<input type="checkbox" class="form-check-input"
@bind="_advancedCoalesceProhibited" @bind:after="OnAdvancedChanged"/>
<label class="form-check-label">Read in isolation (#143)</label>
</div>
<div class="form-text">Use when surrounding registers are write-only or fault on read.</div>
</div>
</div>
}
}
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
<div class="mt-3">
@@ -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)
/// <summary>
/// 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.
/// </summary>
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<string, object?>();
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()

View File

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