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 51fa593f..9baa9f94 100644 --- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json +++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json @@ -19,7 +19,7 @@ { "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": "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": "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": "pending", "blockedBy": [] }, { "id": "T20", "subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)", "status": "pending", "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"] }, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index d36d6f0b..d2ba8789 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -68,7 +68,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, _logger = logger ?? NullLogger.Instance; _resolver = new EquipmentTagRefResolver( r => _tagsByName.TryGetValue(r, out var t) ? t : null, - r => FocasEquipmentTagParser.TryParse(r, out var d) ? d : null); + ResolveEquipmentTagRef); _poll = new PollGroupEngine( reader: ReadAsync, onChange: (handle, tagRef, snapshot) => @@ -234,6 +234,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, internal bool IsParsedAddressCached(string reference) => _parsedAddressesByTagName.ContainsKey(reference); + // Resolves an equipment-tag TagConfig reference into a FocasTagDefinition, applying the SAME + // capability pre-flight authored tags get at InitializeAsync (:105-119) — R2-11 (05/UNDER-6). A + // reference whose address is unparseable, whose bound device is unknown, or whose address violates + // the device series' capability matrix returns null ⇒ resolver miss ⇒ BadNodeIdUnknown at read time, + // instead of a wrong-status read reaching the wire. The negative is cached by the resolver. + private FocasTagDefinition? ResolveEquipmentTagRef(string reference) + { + if (!FocasEquipmentTagParser.TryParse(reference, out var def)) return null; + var parsed = FocasAddress.TryParse(def.Address); + if (parsed is null) return null; + if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) return null; + return FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is null ? def : null; + } + // Resolves a tag definition to its parsed FocasAddress, caching the result so that // equipment tags (resolver-produced, not seeded at InitializeAsync) don't re-parse the // address string on every ReadAsync / WriteAsync hot-path call. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagCapabilityGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagCapabilityGateTests.cs new file mode 100644 index 00000000..bda91d72 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagCapabilityGateTests.cs @@ -0,0 +1,61 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// R2-11 (05/UNDER-6): an equipment-tag reference whose address violates the device series' +/// capability matrix must fail to RESOLVE (surfacing BadNodeIdUnknown) — the same pre-flight +/// authored tags get at InitializeAsync — instead of reaching the wire and failing later with a +/// wire error. A capability-valid equipment tag still resolves + reads. +/// +[Trait("Category", "Unit")] +public sealed class FocasEquipmentTagCapabilityGateTests +{ + private const string Host = "focas://10.0.0.5:8193"; + + private static FocasDriver NewDriver() => new(new FocasDriverOptions + { + // Series 16i: macro range 0..999 — MACRO:9500 is out of range. + Devices = [new FocasDeviceOptions(Host, Series: FocasCncSeries.Sixteen_i)], + Probe = new FocasProbeOptions { Enabled = false }, + }, "drv-1", new FakeFocasClientFactory()); + + private static string EquipTag(string address) => + $"{{\"address\":\"{address}\",\"dataType\":\"Int32\",\"deviceHostAddress\":\"{Host}\"}}"; + + [Fact] + public async Task Capability_violating_equipment_tag_does_not_resolve() + { + var drv = NewDriver(); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.ReadAsync([EquipTag("MACRO:9500")], CancellationToken.None); + + results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown); + } + + [Fact] + public async Task Capability_valid_equipment_tag_resolves_and_reads() + { + var drv = NewDriver(); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.ReadAsync([EquipTag("MACRO:100")], CancellationToken.None); + + results.Single().StatusCode.ShouldNotBe(FocasStatusMapper.BadNodeIdUnknown); + } + + [Fact] + public async Task Equipment_tag_for_unknown_device_does_not_resolve() + { + var drv = NewDriver(); + await drv.InitializeAsync("{}", CancellationToken.None); + + var badDevice = "{\"address\":\"MACRO:100\",\"dataType\":\"Int32\",\"deviceHostAddress\":\"focas://10.9.9.9:8193\"}"; + var results = await drv.ReadAsync([badDevice], CancellationToken.None); + + results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown); + } +}