From 8401055c9d831bfb062ef27f104bc8bb18aee4a7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 11:09:35 -0400 Subject: [PATCH] =?UTF-8?q?feat(controlplane):=20EquipmentTagConfigInspect?= =?UTF-8?q?or=20=E2=80=94=20DriverType-dispatched=20deploy-time=20TagConfi?= =?UTF-8?q?g=20inspection=20(R2-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tagconfig-consolidation-plan.md.tasks.json | 2 +- .../EquipmentTagConfigInspector.cs | 47 ++++++++++++++ .../ZB.MOM.WW.OtOpcUa.ControlPlane.csproj | 8 +++ .../EquipmentTagConfigInspectorTests.cs | 62 +++++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorTests.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 e014d4c4..297e1971 100644 --- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json +++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json @@ -21,7 +21,7 @@ { "id": "T17", "subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)", "status": "completed", "blockedBy": ["T11"] }, { "id": "T18", "subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)", "status": "completed", "blockedBy": ["T17"] }, { "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": "pending", "blockedBy": ["T12", "T13", "T14", "T15", "T16", "T17"] }, + { "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": "pending", "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": "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"] }, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs new file mode 100644 index 00000000..852ec7c9 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs @@ -0,0 +1,47 @@ +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; +using ZB.MOM.WW.OtOpcUa.Driver.Modbus; +using ZB.MOM.WW.OtOpcUa.Driver.S7; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; + +/// +/// Deploy-time TagConfig inspection dispatched by DriverType (R2-11, 05/CONV-2). Mirrors the +/// AdminUI TagConfigValidator shape: a case-insensitive map from the canonical driver-type string +/// to that driver's lightweight Contracts parser Inspect(), returning human-readable +/// warnings (present-but-invalid enum values that the lenient runtime silently defaults, plus +/// structurally-unparseable TagConfig). Unmapped drivers (Galaxy, OpcUaClient) are skipped exactly as in +/// the AdminUI validator. Never throws. +/// +public static class EquipmentTagConfigInspector +{ + private static readonly IReadOnlyDictionary>> Inspectors = + new Dictionary>>(StringComparer.OrdinalIgnoreCase) + { + ["Modbus"] = ModbusEquipmentTagParser.Inspect, + ["S7"] = S7EquipmentTagParser.Inspect, + ["AbCip"] = AbCipEquipmentTagParser.Inspect, + ["AbLegacy"] = AbLegacyEquipmentTagParser.Inspect, + ["TwinCat"] = TwinCATEquipmentTagParser.Inspect, + ["Focas"] = FocasEquipmentTagParser.Inspect, + }; + + /// Inspects a tag's TagConfig for the given driver type. Returns the driver's warnings, + /// or an empty list when the driver type is unmapped (Galaxy/OpcUaClient) or the inputs are null. + /// The bound driver's DriverType (canonical string, case-insensitive). + /// The equipment tag's TagConfig JSON. + /// The warnings; empty when the driver is unmapped or clean. + public static IReadOnlyList Inspect(string? driverType, string? tagConfig) + { + if (string.IsNullOrEmpty(driverType) || tagConfig is null) return Array.Empty(); + return Inspectors.TryGetValue(driverType, out var inspect) ? inspect(tagConfig) : Array.Empty(); + } + + /// Whether the given driver type has a mapped inspector (equipment-tag protocol drivers only). + /// The bound driver's DriverType. + /// when an inspector is registered for the driver type. + public static bool IsMapped(string? driverType) => + !string.IsNullOrEmpty(driverType) && Inspectors.ContainsKey(driverType); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj index 87fe34cc..4c0ed37c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj @@ -26,6 +26,14 @@ + + + + + + + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorTests.cs new file mode 100644 index 00000000..b99a5052 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorTests.cs @@ -0,0 +1,62 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; + +namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; + +/// R2-11 (05/CONV-2): the DriverType-dispatched deploy-time TagConfig inspector — known drivers +/// route to their parser's Inspect; unmapped drivers (Galaxy/OpcUaClient) skip; keys are case-insensitive. +public sealed class EquipmentTagConfigInspectorTests +{ + [Fact] + public void Modbus_typo_dispatches_to_one_warning() + { + var warnings = EquipmentTagConfigInspector.Inspect( + "Modbus", "{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Intt16\"}"); + warnings.ShouldHaveSingleItem(); + warnings[0].ShouldContain("Intt16"); + } + + [Fact] + public void Clean_modbus_config_has_no_warnings() + { + EquipmentTagConfigInspector.Inspect( + "Modbus", "{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}") + .ShouldBeEmpty(); + } + + [Theory] + [InlineData("GalaxyMxGateway")] + [InlineData("OpcUaClient")] + [InlineData("Unknown")] + public void Unmapped_driver_types_are_skipped(string driverType) + { + EquipmentTagConfigInspector.Inspect(driverType, "{\"dataType\":\"whatever\"}").ShouldBeEmpty(); + EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeFalse(); + } + + [Theory] + [InlineData("modbus")] + [InlineData("FOCAS")] + [InlineData("focas")] + [InlineData("twincat")] + public void Driver_type_matching_is_case_insensitive(string driverType) + { + EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeTrue(); + } + + [Fact] + public void Focas_writable_true_dispatches_read_only_warning() + { + var warnings = EquipmentTagConfigInspector.Inspect( + "Focas", "{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}"); + warnings.ShouldContain(w => w.Contains("read-only")); + } + + [Fact] + public void Null_inputs_are_safe() + { + EquipmentTagConfigInspector.Inspect(null, "{}").ShouldBeEmpty(); + EquipmentTagConfigInspector.Inspect("Modbus", null).ShouldBeEmpty(); + } +}