feat(uns): Modbus typed tag-config editor (F-uns-1 T3)
This commit is contained in:
+52
@@ -0,0 +1,52 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4"><label class="form-label">Region</label>
|
||||
<select class="form-select form-select-sm" value="@_m.Region" @onchange="@(e => Update(() => _m.Region = ParseEnum(e.Value, ModbusRegion.HoldingRegisters)))">
|
||||
@foreach (var v in Enum.GetValues<ModbusRegion>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-md-2"><label class="form-label">Address</label>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.Address" @onchange="@(e => Update(() => _m.Address = ParseInt(e.Value)))" /></div>
|
||||
<div class="col-md-3"><label class="form-label">Data type</label>
|
||||
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, ModbusDataType.Int16)))">
|
||||
@foreach (var v in Enum.GetValues<ModbusDataType>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-md-3"><label class="form-label">Byte order</label>
|
||||
<select class="form-select form-select-sm" value="@_m.ByteOrder" @onchange="@(e => Update(() => _m.ByteOrder = ParseEnum(e.Value, ModbusByteOrder.BigEndian)))">
|
||||
@foreach (var v in Enum.GetValues<ModbusByteOrder>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-md-2"><label class="form-label">Bit index</label>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.BitIndex" @onchange="@(e => Update(() => _m.BitIndex = ParseInt(e.Value)))" /></div>
|
||||
<div class="col-md-2"><label class="form-label">String len</label>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.StringLength" @onchange="@(e => Update(() => _m.StringLength = ParseInt(e.Value)))" /></div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? ConfigJson { get; set; }
|
||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||
|
||||
private ModbusTagConfigModel _m = new();
|
||||
private string? _lastConfigJson;
|
||||
|
||||
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
|
||||
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = ModbusTagConfigModel.FromJson(ConfigJson);
|
||||
}
|
||||
|
||||
private static int ParseInt(object? v, int fallback = 0) => int.TryParse(v?.ToString(), out var i) ? i : fallback;
|
||||
|
||||
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
|
||||
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
|
||||
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>Typed working model for a Modbus tag's TagConfig JSON (the driver-specific addressing/encoding
|
||||
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
public sealed class ModbusTagConfigModel
|
||||
{
|
||||
/// <summary>Register region (HoldingRegisters/InputRegisters/Coils/DiscreteInputs).</summary>
|
||||
public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegisters;
|
||||
|
||||
/// <summary>Starting register/coil address.</summary>
|
||||
public int Address { get; set; }
|
||||
|
||||
/// <summary>Wire value type the register block decodes to.</summary>
|
||||
public ModbusDataType DataType { get; set; } = ModbusDataType.Int16;
|
||||
|
||||
/// <summary>Word/byte ordering for multi-register values.</summary>
|
||||
public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian;
|
||||
|
||||
/// <summary>Bit index (0-15) for BitInRegister tags.</summary>
|
||||
public int BitIndex { get; set; }
|
||||
|
||||
/// <summary>String length in characters for String tags.</summary>
|
||||
public int StringLength { get; set; }
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
|
||||
public static ModbusTagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new ModbusTagConfigModel
|
||||
{
|
||||
Region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters),
|
||||
Address = TagConfigJson.GetInt(o, "address"),
|
||||
DataType = TagConfigJson.GetEnum(o, "dataType", ModbusDataType.Int16),
|
||||
ByteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
|
||||
BitIndex = TagConfigJson.GetInt(o, "bitIndex"),
|
||||
StringLength = TagConfigJson.GetInt(o, "stringLength"),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serialises this model back to a TagConfig JSON string, writing the six exposed fields
|
||||
/// (enums as their name strings) over the preserved key bag.</summary>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "region", Region);
|
||||
TagConfigJson.Set(_bag, "address", Address);
|
||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||
TagConfigJson.Set(_bag, "byteOrder", ByteOrder);
|
||||
TagConfigJson.Set(_bag, "bitIndex", BitIndex);
|
||||
TagConfigJson.Set(_bag, "stringLength", StringLength);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
public string? Validate() => null;
|
||||
}
|
||||
@@ -10,8 +10,8 @@ public static class TagConfigEditorMap
|
||||
private static readonly IReadOnlyDictionary<string, Type> Map =
|
||||
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Editors registered by later tasks, e.g.:
|
||||
// ["ModbusTcp"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor),
|
||||
["ModbusTcp"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor),
|
||||
// Further editors registered by later tasks, e.g. ["AbCip"] = typeof(...).
|
||||
};
|
||||
|
||||
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
public sealed class ModbusTagConfigModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{}")]
|
||||
public void FromJson_returns_defaults_for_empty_input(string? json)
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson(json);
|
||||
|
||||
m.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
m.Address.ShouldBe(0);
|
||||
m.DataType.ShouldBe(ModbusDataType.Int16);
|
||||
m.ByteOrder.ShouldBe(ModbusByteOrder.BigEndian);
|
||||
m.BitIndex.ShouldBe(0);
|
||||
m.StringLength.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_all_six_fields()
|
||||
{
|
||||
var m = new ModbusTagConfigModel
|
||||
{
|
||||
Region = ModbusRegion.InputRegisters,
|
||||
Address = 40001,
|
||||
DataType = ModbusDataType.Float32,
|
||||
ByteOrder = ModbusByteOrder.WordSwap,
|
||||
BitIndex = 3,
|
||||
StringLength = 16,
|
||||
};
|
||||
|
||||
var json = m.ToJson();
|
||||
var m2 = ModbusTagConfigModel.FromJson(json);
|
||||
|
||||
m2.Region.ShouldBe(ModbusRegion.InputRegisters);
|
||||
m2.Address.ShouldBe(40001);
|
||||
m2.DataType.ShouldBe(ModbusDataType.Float32);
|
||||
m2.ByteOrder.ShouldBe(ModbusByteOrder.WordSwap);
|
||||
m2.BitIndex.ShouldBe(3);
|
||||
m2.StringLength.ShouldBe(16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_emits_camelCase_keys_with_enum_names()
|
||||
{
|
||||
var m = new ModbusTagConfigModel
|
||||
{
|
||||
Region = ModbusRegion.HoldingRegisters,
|
||||
Address = 100,
|
||||
DataType = ModbusDataType.Int16,
|
||||
ByteOrder = ModbusByteOrder.BigEndian,
|
||||
BitIndex = 0,
|
||||
StringLength = 0,
|
||||
};
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
json.ShouldContain("\"region\":\"HoldingRegisters\"");
|
||||
json.ShouldContain("\"dataType\":\"Int16\"");
|
||||
json.ShouldContain("\"byteOrder\":\"BigEndian\"");
|
||||
json.ShouldContain("\"address\":100");
|
||||
json.ShouldContain("\"bitIndex\":0");
|
||||
json.ShouldContain("\"stringLength\":0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_unknown_keys()
|
||||
{
|
||||
var json = ModbusTagConfigModel
|
||||
.FromJson("""{"region":"InputRegisters","addressString":"40001:F"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("addressString");
|
||||
json.ShouldContain("40001:F");
|
||||
// and the exposed field still round-trips
|
||||
json.ShouldContain("\"region\":\"InputRegisters\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_null_for_default_model()
|
||||
{
|
||||
new ModbusTagConfigModel().Validate().ShouldBeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user