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 b25ae8bb..51fa593f 100644 --- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json +++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json @@ -18,7 +18,7 @@ { "id": "T14", "subject": "AbCip equipment-tag parser: freeze test, shared readers (writable already honoured), Inspect warnings", "status": "completed", "blockedBy": ["T11"] }, { "id": "T15", "subject": "AbLegacy equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] }, { "id": "T16", "subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] }, - { "id": "T17", "subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)", "status": "pending", "blockedBy": ["T11"] }, + { "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": "pending", "blockedBy": ["T17"] }, { "id": "T19", "subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)", "status": "pending", "blockedBy": [] }, { "id": "T20", "subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)", "status": "pending", "blockedBy": ["T12", "T13", "T14", "T15", "T16", "T17"] }, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs index 94bd1000..0e47e16e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; @@ -28,11 +29,17 @@ public static class FocasEquipmentTagParser return false; var address = addr.GetString(); if (string.IsNullOrWhiteSpace(address)) return false; - var dataType = ReadEnum(root, "dataType", FocasDataType.Int32); + var dataType = TagConfigJson.ReadEnumOrDefault(root, "dataType", FocasDataType.Int32); var deviceHostAddress = ReadString(root, "deviceHostAddress") ?? ""; def = new FocasTagDefinition( Name: reference, DeviceHostAddress: deviceHostAddress, Address: address, - DataType: dataType, Writable: true); + // FOCAS equipment tags are FORCED read-only (05/UNDER-1): the only wire client + // (WireFocasClient.cs:71-73) returns BadNotWritable for EVERY address, so advertising a + // writable node is a lie — a write that used to reach the backend and always fail with + // BadNotWritable now fails at the driver seam with the same family of Bad status (no + // successful operation changes). Any authored `writable:true` is ignored + warned by + // Inspect. Honour the key here once PMC writes ship in the wire client. + DataType: dataType, Writable: false); return true; } catch (JsonException) { return false; } @@ -40,9 +47,38 @@ public static class FocasEquipmentTagParser catch (InvalidOperationException) { return false; } } - private static TEnum ReadEnum(JsonElement o, string name, TEnum fallback) where TEnum : struct, Enum - => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String - && Enum.TryParse(e.GetString(), ignoreCase: true, out var v) ? v : fallback; + /// + /// Deploy-time inspection (05/CONV-2, 05/UNDER-1): warns on a present-but-invalid dataType + /// (silently defaulted by the lenient runtime), on an authored writable:true (FOCAS writes + /// are unsupported — the tag is forced read-only), and on a structurally unparseable TagConfig. + /// Empty when clean or not an equipment-tag object. Never throws. + /// + /// The equipment tag's TagConfig JSON. + /// The warnings; empty when clean. + public static IReadOnlyList Inspect(string reference) + { + var warnings = new List(); + if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings; + try + { + using var doc = JsonDocument.Parse(reference); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + warnings.Add("FOCAS TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown)."); + return warnings; + } + var w = TagConfigJson.DescribeInvalidEnum(root, "dataType"); + if (w is not null) warnings.Add(w); + if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True) + warnings.Add("FOCAS writes unsupported — 'writable:true' is ignored; the tag is forced read-only until PMC writes exist."); + } + catch (JsonException) + { + warnings.Add("FOCAS TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); + } + return warnings; + } private static string? ReadString(JsonElement o, string name) => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj index 3896f7bd..48b2f1bd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj @@ -5,5 +5,10 @@ enable true - + + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagParserStrictnessTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagParserStrictnessTests.cs new file mode 100644 index 00000000..1b487531 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagParserStrictnessTests.cs @@ -0,0 +1,57 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// R2-11 (05/CONV-2 + 05/UNDER-1) strictness surface for the FOCAS equipment-tag parser: lenient +/// runtime freeze on dataType, forced read-only (Writable == false regardless of any +/// authored writable:true), and Inspect reporting the typo + the write-request. +/// +public sealed class FocasEquipmentTagParserStrictnessTests +{ + [Fact] + public void Freeze_typo_dataType_still_defaults_to_Int32() + { + FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int322\"}", out var def) + .ShouldBeTrue(); + def.DataType.ShouldBe(FocasDataType.Int32); + } + + [Fact] + public void Writable_is_always_false_even_when_requested_true() + { + FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}", out var def) + .ShouldBeTrue(); + def.Writable.ShouldBeFalse(); + } + + [Fact] + public void Writable_is_false_when_absent() + { + FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int32\"}", out var def) + .ShouldBeTrue(); + def.Writable.ShouldBeFalse(); + } + + [Fact] + public void Inspect_warns_on_writable_true_request() + { + var warnings = FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}"); + warnings.ShouldContain(w => w.Contains("read-only") && w.Contains("writable")); + } + + [Fact] + public void Inspect_reports_typo_dataType() + { + var warnings = FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int322\"}"); + warnings.ShouldContain(w => w.Contains("Int322") && w.Contains("dataType")); + } + + [Fact] + public void Inspect_clean_read_only_config_has_no_warnings() + { + FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\"}").ShouldBeEmpty(); + } +}