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();
}