From 4449baac692d538aa0bf175d511f2960d605c64d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 11:22:12 -0400 Subject: [PATCH] feat(adminui): writable checkbox in driver-typed tag editors (R2-11, 05/UNDER-6) Models gain an optional bool? Writable (absent stays omitted; explicit false round-trips, unknown keys preserved); the six editors gain a Writable checkbox (FOCAS disabled+read-only hint). 9 model round-trip tests green. docker-dev /run verify of one editor deferred-live. --- ...tagconfig-consolidation-plan.md.tasks.json | 2 +- .../Uns/TagEditors/AbCipTagConfigEditor.razor | 8 ++ .../TagEditors/AbLegacyTagConfigEditor.razor | 8 ++ .../Uns/TagEditors/FocasTagConfigEditor.razor | 6 ++ .../TagEditors/ModbusTagConfigEditor.razor | 8 ++ .../Uns/TagEditors/S7TagConfigEditor.razor | 8 ++ .../TagEditors/TwinCATTagConfigEditor.razor | 8 ++ .../Uns/TagEditors/AbCipTagConfigModel.cs | 6 ++ .../Uns/TagEditors/AbLegacyTagConfigModel.cs | 6 ++ .../Uns/TagEditors/ModbusTagConfigModel.cs | 7 ++ .../Uns/TagEditors/S7TagConfigModel.cs | 6 ++ .../Uns/TagEditors/TagConfigJson.cs | 8 ++ .../Uns/TagEditors/TwinCATTagConfigModel.cs | 6 ++ .../Uns/TagConfigModelWritableTests.cs | 88 +++++++++++++++++++ 14 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigModelWritableTests.cs diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json index 239b3a6e..a19dfd74 100644 --- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json +++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json @@ -23,7 +23,7 @@ { "id": "T19", "subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)", "status": "completed", "blockedBy": [] }, { "id": "T20", "subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)", "status": "completed", "blockedBy": ["T12", "T13", "T14", "T15", "T16", "T17"] }, { "id": "T21", "subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test", "status": "completed", "blockedBy": ["T20"] }, - { "id": "T22", "subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)", "status": "pending", "blockedBy": [] }, + { "id": "T22", "subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)", "status": "deferred-live", "blockedBy": [] }, { "id": "T23", "subject": "Docs + decision record: two-mode rollout, unknown-keys non-goal, FOCAS writable migration note, STATUS.md/00-INDEX.md entries + Phase-C follow-up", "status": "pending", "blockedBy": ["T17", "T21"] }, { "id": "T24", "subject": "Final sweep: zero parity-comment/ReadEnum-copy grep hits; whole-solution build + test gate", "status": "pending", "blockedBy": ["T5", "T6", "T7", "T8", "T9", "T10", "T12", "T13", "T14", "T15", "T16", "T17", "T18", "T19", "T21", "T22", "T23"] } ] diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor index 4d4d8f83..27328ab8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor @@ -17,6 +17,14 @@ +
+
+ + +
+
@if (_showPicker) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor index 2bdc9e6d..8f770103 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor @@ -17,6 +17,14 @@ +
+
+ + +
+
@if (_showPicker) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor index 1a0a5a00..5eec1f55 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor @@ -17,6 +17,12 @@ +
+
+ + +
+
@if (_showPicker) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor index b59ba9ac..b9449866 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor @@ -25,6 +25,14 @@
+
+
+ + +
+
@if (_showPicker) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor index 9f1d8611..aba5047a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor @@ -17,6 +17,14 @@
+
+
+ + +
+
@if (_showPicker) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor index 7d7f9c80..143fdee0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor @@ -17,6 +17,14 @@ +
+
+ + +
+
@if (_showPicker) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbCipTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbCipTagConfigModel.cs index efb64254..4dd9044c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbCipTagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbCipTagConfigModel.cs @@ -16,6 +16,10 @@ public sealed class AbCipTagConfigModel /// Logix atomic type, or AbCipDataType.Structure for UDT-typed tags. public AbCipDataType DataType { get; set; } = AbCipDataType.DInt; + /// Optional writable flag (R2-11); null ⇒ omitted (driver default true), explicit + /// false ⇒ read-only. Preserved across load→save. + public bool? Writable { get; set; } + private JsonObject _bag = new(); /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining @@ -30,6 +34,7 @@ public sealed class AbCipTagConfigModel DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "", TagPath = TagConfigJson.GetString(o, "tagPath") ?? "", DataType = TagConfigJson.GetEnum(o, "dataType", AbCipDataType.DInt), + Writable = TagConfigJson.GetBoolNullable(o, "writable"), _bag = o, }; } @@ -42,6 +47,7 @@ public sealed class AbCipTagConfigModel TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim()); TagConfigJson.Set(_bag, "tagPath", TagPath.Trim()); TagConfigJson.Set(_bag, "dataType", DataType); + TagConfigJson.Set(_bag, "writable", Writable); return TagConfigJson.Serialize(_bag); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs index cf0d2dd1..3f297905 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs @@ -16,6 +16,10 @@ public sealed class AbLegacyTagConfigModel /// PCCC data type that maps onto SLC / MicroLogix / PLC-5 files. public AbLegacyDataType DataType { get; set; } = AbLegacyDataType.Int; + /// Optional writable flag (R2-11); null ⇒ omitted (driver default true), explicit + /// false ⇒ read-only. Preserved across load→save. + public bool? Writable { get; set; } + private JsonObject _bag = new(); /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining @@ -30,6 +34,7 @@ public sealed class AbLegacyTagConfigModel DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "", Address = TagConfigJson.GetString(o, "address") ?? "", DataType = TagConfigJson.GetEnum(o, "dataType", AbLegacyDataType.Int), + Writable = TagConfigJson.GetBoolNullable(o, "writable"), _bag = o, }; } @@ -42,6 +47,7 @@ public sealed class AbLegacyTagConfigModel TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim()); TagConfigJson.Set(_bag, "address", Address.Trim()); TagConfigJson.Set(_bag, "dataType", DataType); + TagConfigJson.Set(_bag, "writable", Writable); return TagConfigJson.Serialize(_bag); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs index 127da898..3ac57284 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs @@ -25,6 +25,11 @@ public sealed class ModbusTagConfigModel /// String length in characters for String tags. public int StringLength { get; set; } + /// Optional writable flag (R2-11). null ⇒ the key is omitted (driver default true); + /// an explicit false makes the equipment tag read-only. Preserved across load→save so an absent + /// key stays absent. + public bool? Writable { get; set; } + private JsonObject _bag = new(); /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining @@ -42,6 +47,7 @@ public sealed class ModbusTagConfigModel ByteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian), BitIndex = TagConfigJson.GetInt(o, "bitIndex"), StringLength = TagConfigJson.GetInt(o, "stringLength"), + Writable = TagConfigJson.GetBoolNullable(o, "writable"), _bag = o, }; } @@ -57,6 +63,7 @@ public sealed class ModbusTagConfigModel TagConfigJson.Set(_bag, "byteOrder", ByteOrder); TagConfigJson.Set(_bag, "bitIndex", BitIndex); TagConfigJson.Set(_bag, "stringLength", StringLength); + TagConfigJson.Set(_bag, "writable", Writable); return TagConfigJson.Serialize(_bag); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs index 7d561a71..6cd40543 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs @@ -16,6 +16,10 @@ public sealed class S7TagConfigModel /// For DataType = String: S7-string max length (max 254). public int StringLength { get; set; } + /// Optional writable flag (R2-11); null ⇒ omitted (driver default true), explicit + /// false ⇒ read-only. Preserved across load→save. + public bool? Writable { get; set; } + private JsonObject _bag = new(); /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining @@ -30,6 +34,7 @@ public sealed class S7TagConfigModel Address = TagConfigJson.GetString(o, "address") ?? "", DataType = TagConfigJson.GetEnum(o, "dataType", S7DataType.Int16), StringLength = TagConfigJson.GetInt(o, "stringLength"), + Writable = TagConfigJson.GetBoolNullable(o, "writable"), _bag = o, }; } @@ -42,6 +47,7 @@ public sealed class S7TagConfigModel TagConfigJson.Set(_bag, "address", Address.Trim()); TagConfigJson.Set(_bag, "dataType", DataType); TagConfigJson.Set(_bag, "stringLength", StringLength); + TagConfigJson.Set(_bag, "writable", Writable); return TagConfigJson.Serialize(_bag); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs index 69d6ac4b..01624144 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs @@ -48,6 +48,14 @@ public static class TagConfigJson public static bool GetBool(JsonObject o, string name, bool fallback = false) => o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue(out var b) ? b : fallback; + /// Reads a bool value, or null when the property is absent/null/non-boolean — so an + /// absent key stays absent through a load→save (distinguishes "not set" from an explicit false). + /// The JSON object to read from. + /// The property name to read. + /// The bool value, or null when absent/null/non-boolean. + public static bool? GetBoolNullable(JsonObject o, string name) + => o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue(out var b) ? b : null; + /// Reads an enum by its serialised name, or if absent/unparseable. /// The enum type to parse. /// The JSON object to read from. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TwinCATTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TwinCATTagConfigModel.cs index b4756ed4..ec9961ea 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TwinCATTagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TwinCATTagConfigModel.cs @@ -16,6 +16,10 @@ public sealed class TwinCATTagConfigModel /// TwinCAT / IEC 61131-3 atomic data type. public TwinCATDataType DataType { get; set; } = TwinCATDataType.DInt; + /// Optional writable flag (R2-11); null ⇒ omitted (driver default true), explicit + /// false ⇒ read-only. Preserved across load→save. + public bool? Writable { get; set; } + private JsonObject _bag = new(); /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining @@ -30,6 +34,7 @@ public sealed class TwinCATTagConfigModel DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "", SymbolPath = TagConfigJson.GetString(o, "symbolPath") ?? "", DataType = TagConfigJson.GetEnum(o, "dataType", TwinCATDataType.DInt), + Writable = TagConfigJson.GetBoolNullable(o, "writable"), _bag = o, }; } @@ -42,6 +47,7 @@ public sealed class TwinCATTagConfigModel TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim()); TagConfigJson.Set(_bag, "symbolPath", SymbolPath.Trim()); TagConfigJson.Set(_bag, "dataType", DataType); + TagConfigJson.Set(_bag, "writable", Writable); return TagConfigJson.Serialize(_bag); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigModelWritableTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigModelWritableTests.cs new file mode 100644 index 00000000..fc5dcabf --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigModelWritableTests.cs @@ -0,0 +1,88 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// R2-11 (05/UNDER-6): the new optional writable key round-trips through the driver-typed tag +/// config models — a value the editors' new checkbox authors. Absent stays absent (so existing configs +/// are untouched); an explicit false round-trips; unknown keys are preserved. +/// +public sealed class TagConfigModelWritableTests +{ + [Fact] + public void Modbus_absent_writable_stays_omitted() + { + var m = ModbusTagConfigModel.FromJson("{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}"); + m.Writable.ShouldBeNull(); + m.ToJson().ShouldNotContain("writable"); + } + + [Fact] + public void Modbus_explicit_false_round_trips() + { + var m = ModbusTagConfigModel.FromJson("{\"address\":1,\"writable\":false}"); + m.Writable.ShouldBe(false); + var json = ModbusTagConfigModel.FromJson(m.ToJson()); + json.Writable.ShouldBe(false); + } + + [Fact] + public void Modbus_setting_writable_true_writes_the_key() + { + var m = ModbusTagConfigModel.FromJson("{\"address\":1}"); + m.Writable = true; + ModbusTagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(true); + } + + [Fact] + public void Writable_preserves_unknown_keys() + { + var m = ModbusTagConfigModel.FromJson("{\"address\":1,\"writable\":false,\"foo\":\"bar\"}"); + m.ToJson().ShouldContain("foo"); + m.ToJson().ShouldContain("bar"); + } + + [Fact] + public void S7_writable_round_trips() + { + var m = S7TagConfigModel.FromJson("{\"address\":\"DB1.DBW0\",\"writable\":false}"); + m.Writable.ShouldBe(false); + S7TagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(false); + } + + [Fact] + public void AbCip_writable_round_trips() + { + var m = AbCipTagConfigModel.FromJson("{\"tagPath\":\"Motor.Speed\",\"writable\":false}"); + m.Writable.ShouldBe(false); + AbCipTagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(false); + } + + [Fact] + public void AbLegacy_writable_round_trips() + { + var m = AbLegacyTagConfigModel.FromJson("{\"address\":\"N7:0\",\"writable\":false}"); + m.Writable.ShouldBe(false); + AbLegacyTagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(false); + } + + [Fact] + public void TwinCAT_writable_round_trips() + { + var m = TwinCATTagConfigModel.FromJson("{\"symbolPath\":\"MAIN.x\",\"writable\":false}"); + m.Writable.ShouldBe(false); + TwinCATTagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(false); + } + + [Fact] + public void Absent_writable_omitted_across_models() + { + ModbusTagConfigModel.FromJson("{\"address\":1}").ToJson().ShouldNotContain("writable"); + S7TagConfigModel.FromJson("{\"address\":\"DB1.DBW0\"}").ToJson().ShouldNotContain("writable"); + AbCipTagConfigModel.FromJson("{\"tagPath\":\"M.S\"}").ToJson().ShouldNotContain("writable"); + AbLegacyTagConfigModel.FromJson("{\"address\":\"N7:0\"}").ToJson().ShouldNotContain("writable"); + TwinCATTagConfigModel.FromJson("{\"symbolPath\":\"MAIN.x\"}").ToJson().ShouldNotContain("writable"); + } +}