From b78c7ff9db47bbac698316feaf420adfe85b4052 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 06:51:40 -0400 Subject: [PATCH] feat(mtconnect): authoring DataType warning + loud ignored-DeviceName warning (#613 slice) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- docs/drivers/MTConnect.md | 17 ++++++- .../MTConnectDriver.cs | 30 ++++++++++++ .../TagEditors/MTConnectTagConfigEditor.razor | 4 ++ .../Uns/TagEditors/MTConnectTagConfigModel.cs | 49 +++++++++++++++++++ .../MTConnectRawTagBindingTests.cs | 31 ++++++++++++ .../Uns/MTConnectTagConfigModelTests.cs | 33 +++++++++++++ 6 files changed, 162 insertions(+), 2 deletions(-) diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index a2d62af9..3768a4c1 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -397,8 +397,21 @@ for anything beyond values-and-conditions. `DeviceName` (see [Write-back](#write-back--observation-injection-off-by-default)). So `MTConnectDriverOptions.DeviceName` does double duty — request scope for reads/browse, fallback write target for writes. -3. **Nothing validates a raw tag's declared OPC UA `DataType` against the - blob's coercion type.** The `/probe`-sourced inference + + **It is no longer silent (#613).** When the instance's raw tags carry two or + more distinct non-blank `RawTagEntry.DeviceName` values, driver init logs one + Warning naming them and the driver-level scope they collapse into. A single + value is not reported (it is just the `/raw` Device's own name). Real + per-device routing remains deferred. +3. **Authoring only *warns* when a declared coercion type cannot hold the + inferred one (#613).** The AdminUI tag editor (`MTConnectTagConfigModel.Warnings()`) + compares the declared type with `MTConnectDataTypeInference` over the + `/probe` metadata a browse-commit stamped (`mtCategory`/`mtType`), and shows an + advisory line — it never blocks a save (the deploy gate does not refuse it) and + is a no-op for hand-authored blobs with no stamped metadata. `representation` + is not on the model, so TIME_SERIES arrays are not covered. Beyond that, + nothing validates a raw tag's declared OPC UA `DataType` against the + blob's coercion type. The `/probe`-sourced inference (`MTConnectDataTypeInference`) narrows how often an author picks a wrong type, but it doesn't close the gap — an authored `DataType` mismatched against the actual Agent observation still surfaces as `BadTypeMismatch` diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index 95f76c3e..5001c1aa 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -3232,6 +3232,29 @@ public sealed class MTConnectDriver // ---- v3 tag binding: RawPath ↔ dataItemId, and the definitions the index coerces against ---- + /// + /// Names the distinct, non-blank values when there are two or + /// more, else null (#613). The driver never routes by that field — one instance owns one + /// Agent client — so differing values mean the author expects a split the driver silently does + /// not perform. A single value is NOT reported: it is the /raw Device's own name, which + /// routinely differs from the Agent's device name and would make the warning noise. + /// + /// The authored raw tags. + /// A comma-joined, sorted list of the distinct names, or null when fewer than two. + internal static string? DescribeIgnoredDeviceNames(IReadOnlyList rawTags) + { + var names = new SortedSet(StringComparer.Ordinal); + foreach (var entry in rawTags) + { + if (entry is not null && !string.IsNullOrWhiteSpace(entry.DeviceName)) + { + names.Add(entry.DeviceName.Trim()); + } + } + + return names.Count >= 2 ? string.Join(", ", names) : null; + } + /// /// Derives the driver's tag tables from an options document — the RawPath → dataItemId /// translation the data plane runs through, its reverse, and the definition set the @@ -3295,6 +3318,13 @@ public sealed class MTConnectDriver BuildWriteTargets(only, probeModel, options.DeviceName)); } + if (DescribeIgnoredDeviceNames(options.RawTags) is { } ignoredDevices) + { + _logger.LogWarning( + "MTConnect driver {DriverInstanceId}: raw tags carry differing DeviceName values ({DeviceNames}), but RawTagEntry.DeviceName is not used for routing — every tag is served from the one Agent connection scoped by the driver-level DeviceName ({ConfiguredDevice}). Author one MTConnect driver instance per Agent device if they must be kept apart.", + _driverInstanceId, ignoredDevices, options.DeviceName ?? ""); + } + var declared = IndexProbeDataItems(probeModel); var idByRawPath = new Dictionary(options.RawTags.Count, StringComparer.Ordinal); var rawPathsById = new Dictionary>(StringComparer.Ordinal); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MTConnectTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MTConnectTagConfigEditor.razor index 7a71f951..2db13693 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MTConnectTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MTConnectTagConfigEditor.razor @@ -80,6 +80,10 @@ {
@error
} +@foreach (var warning in _m.Warnings()) +{ +
@warning
+} @code { /// The tag's raw TagConfig JSON (supports @bind-ConfigJson). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs index b68a4429..d1d3997a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs @@ -216,6 +216,55 @@ public sealed class MTConnectTagConfigModel "MTConnect"); } + /// + /// Advisory warnings (#613) — NEVER an error and never routed through : the + /// deploy gate does not refuse these, so authoring must not either ("never stricter than the gate"). + /// One rule: the declared cannot hold the type + /// derives from the stamped + /// /. + /// + /// + /// A no-op unless the probe metadata was stamped (a browse-commit does; hand-authored blobs do not), + /// since there is nothing to infer from otherwise. representation is not on the model so it is + /// passed as null; it can only DEMOTE an item to String (DATA_SET/TABLE), which cannot create a false + /// warning because a numeric declaration on a String-inferred item is warned either way, and a SAMPLE + /// inferred Float64 is only warned about for a non-numeric, non-String declaration or an integer one. + /// TIME_SERIES arrays are not detectable without it and are not covered. + /// + /// Zero or more human-readable warnings. + public IReadOnlyList Warnings() + { + if (string.IsNullOrWhiteSpace(MtCategory)) { return []; } + + var inferred = MTConnectDataTypeInference.Infer(MtCategory, MtType, Units).DataType; + var declared = DataType; + + // String can hold any observation's text, so declaring it is never lossy. + if (declared == inferred || declared == DriverDataType.String) { return []; } + + if (IsNumeric(inferred) && IsNumeric(declared)) + { + // Float64 inferred (a SAMPLE): an integer type cannot hold a fractional reading. A wider-or-equal + // numeric holds an Int64-inferred item (PartCount, Line). + return IsIntegral(declared) && !IsIntegral(inferred) + ? [$"Data type {declared} cannot hold fractional values; the Agent's /probe metadata " + + $"({MtCategory}/{MtType}) infers {inferred}. Fractional readings will surface as a bad status."] + : []; + } + + return + [ + $"Data type {declared} does not match the type inferred from the Agent's /probe metadata " + + $"({MtCategory}/{MtType} -> {inferred}). Observations that do not parse as {declared} will surface " + + "as BadTypeMismatch/BadOutOfRange at runtime. Saving is still allowed." + ]; + } + + private static bool IsNumeric(DriverDataType t) => t is not (DriverDataType.Boolean or DriverDataType.String + or DriverDataType.DateTime or DriverDataType.Reference); + + private static bool IsIntegral(DriverDataType t) => IsNumeric(t) && t is not (DriverDataType.Float32 or DriverDataType.Float64); + // The as-loaded coercion-type TOKEN as text (see the _rawDataType and DataTypeKeys remarks), read in // the RUNTIME's precedence so this model judges — and displays — the token the driver would actually // read. Per key, mirroring FirstNonBlank(blob.DriverDataType, blob.DataType) over a DTO whose two diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectRawTagBindingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectRawTagBindingTests.cs index 9f12645e..d022c070 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectRawTagBindingTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectRawTagBindingTests.cs @@ -117,6 +117,37 @@ public sealed class MTConnectRawTagBindingTests return (driver, agent); } + private static RawTagEntry OnDevice(string rawPath, string dataItemId, string device) => + Entry(rawPath, dataItemId) with { DeviceName = device }; + + private static async Task> InitWarningsAsync(params RawTagEntry[] rawTags) + { + var logger = new RecordingDriverLogger(); + var driver = new MTConnectDriver(Bare(), "mt1", _ => CannedAgentClient.FromFixtures(), logger); + await driver.InitializeAsync(MergedConfig(rawTags), Ct); + await driver.ShutdownAsync(Ct); + + return [.. logger.Warnings]; + } + + /// #613: differing RawTagEntry.DeviceName values are ignored for routing, so init says so. + [Fact] + public async Task Differing_raw_device_names_log_an_ignored_routing_warning() + { + // Positive control: a single /raw Device is the ordinary case and must stay silent. + (await InitWarningsAsync( + OnDevice(PositionRawPath, PositionDataItemId, "cncA"), + OnDevice(ExecutionRawPath, ExecutionDataItemId, "cncA"))) + .ShouldNotContain(w => w.Contains("RawTagEntry.DeviceName")); + + var warnings = await InitWarningsAsync( + OnDevice(PositionRawPath, PositionDataItemId, "cncA"), + OnDevice(ExecutionRawPath, ExecutionDataItemId, "cncB")); + + var w = warnings.Where(x => x.Contains("RawTagEntry.DeviceName")).ShouldHaveSingleItem(); + w.ShouldContain("cncA, cncB"); + } + // ---- the production data plane: subscribe by RawPath, publish by RawPath ---- /// diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MTConnectTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MTConnectTagConfigModelTests.cs index 8374ebe5..a40e62d7 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MTConnectTagConfigModelTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MTConnectTagConfigModelTests.cs @@ -295,4 +295,37 @@ public sealed class MTConnectTagConfigModelTests [InlineData("""{"fullName":"x","driverDataType":8,"dataType":"Int32"}""")] public void Validate_rejects_a_driverDataType_written_as_an_unquoted_number(string json) => MTConnectTagConfigModel.FromJson(json).Validate().ShouldNotBeNull().ShouldContain("8"); + + private static MTConnectTagConfigModel Stamped(string category, string type, string dataType) + => MTConnectTagConfigModel.FromJson( + $"{{\"fullName\":\"x\",\"dataType\":\"{dataType}\",\"mtCategory\":\"{category}\",\"mtType\":\"{type}\"}}"); + + [Theory] + [InlineData("SAMPLE", "POSITION", "Boolean")] // numeric inferred, non-numeric declared + [InlineData("SAMPLE", "POSITION", "Int32")] // fractional readings cannot fit + [InlineData("EVENT", "EXECUTION", "Float64")] // text inferred, numeric declared + [InlineData("CONDITION", "TEMPERATURE", "Int32")] + public void Warnings_flags_a_declared_type_that_cannot_hold_the_inferred_one_without_blocking( + string category, string type, string dataType) + { + var m = Stamped(category, type, dataType); + + m.Warnings().ShouldHaveSingleItem().ShouldContain(dataType); + m.Validate().ShouldBeNull(); // advisory: never blocks + } + + [Theory] + [InlineData("SAMPLE", "POSITION", "Float64")] + [InlineData("SAMPLE", "POSITION", "String")] // text holds anything + [InlineData("EVENT", "EXECUTION", "String")] + [InlineData("EVENT", "PART_COUNT", "Int64")] + [InlineData("EVENT", "PART_COUNT", "Float64")] + public void Warnings_is_quiet_when_the_declared_type_holds_the_inferred_one( + string category, string type, string dataType) + => Stamped(category, type, dataType).Warnings().ShouldBeEmpty(); + + [Fact] + public void Warnings_is_a_noop_without_probe_metadata() + => MTConnectTagConfigModel.FromJson("{\"fullName\":\"x\",\"dataType\":\"Boolean\"}") + .Warnings().ShouldBeEmpty(); }