diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md
index a7659305..210bc10f 100644
--- a/docs/drivers/MTConnect.md
+++ b/docs/drivers/MTConnect.md
@@ -410,14 +410,24 @@ for anything beyond values-and-conditions.
`GetHostStatuses()` fresh, and the result rides `DriverHealthChanged` to
the AdminUI `/hosts` page (no SQL; the `DriverHostStatus` table was
dropped). See `docs/Telemetry.md`.
-2. **`RawTagEntry.DeviceName` is accepted on the wire shape but ignored for
- routing.** One driver instance owns exactly one Agent client scoped by
- `MTConnectDriverOptions.DeviceName` (the top-level config key); the
- per-tag `RawTagEntry.DeviceName` field the v3 raw-tag identity carries is
- never read by the driver. Two `/raw` Devices authored under one MTConnect
- driver instance both resolve to the same one Agent connection. Real
- per-device routing (a client per device) would be a design change, not a
- bug fix.
+2. **One MTConnect driver instance = one Agent client = at most one `/raw`
+ Device (#628, decision (b)).** The driver never reads
+ `RawTagEntry.DeviceName`; every raw tag binds through the single Agent client
+ scoped by `MTConnectDriverOptions.DeviceName`. The v3 raw tree always has a
+ Device level (a new driver auto-creates `Device1`), and that single-Device
+ layout is the normal, valid shape — its `DeviceConfig` is merged up into the
+ driver config by `DriverDeviceConfigMerger` (this is how the endpoint lands).
+ With **two or more** Devices under one MTConnect driver there is no merge-up
+ and no routing, so which Device supplies the endpoint is ambiguous. That is
+ now a **deploy-time `DraftValidator` error** (`MTConnectMultipleDevices`),
+ and the AdminUI refuses to *create* the second Device
+ (`RawTreeService.CreateDeviceAsync`, same message): "MTConnect binds all tags
+ through one agent client per driver instance; author one MTConnect driver per
+ agent device." Option (a), a client per Device, was rejected as a design
+ change with no demand. A multi-device Agent is served by several driver
+ instances, each with its own `DeviceName` scope. The driver's init-time
+ Warning for differing `RawTagEntry.DeviceName` values stays as defence in
+ depth for configs that predate the rule.
**The write path is the one place a device name is resolved per DataItem**,
and it does not use `RawTagEntry.DeviceName` either: it reads the owning
@@ -426,11 +436,6 @@ for anything beyond values-and-conditions.
So `MTConnectDriverOptions.DeviceName` does double duty — request scope for
reads/browse, fallback write target for writes.
- **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
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
index 3543bce2..3f5bebab 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
@@ -41,6 +41,7 @@ public static class DraftValidator
ValidateEquipReferenceResolution(draft, errors);
ValidateCalculationTags(draft, errors);
ValidateSqlConnectionStringNotPersisted(draft, errors);
+ ValidateMTConnectSingleDevice(draft, errors);
ValidateNodeAclScopes(draft, errors);
return errors;
}
@@ -343,6 +344,42 @@ public static class DraftValidator
}
}
+ /// Error code for an MTConnect driver instance that has more than one /raw Device (#628).
+ public const string MTConnectMultipleDevicesCode = "MTConnectMultipleDevices";
+
+ ///
+ /// The one message both the deploy gate and the authoring-time guard (RawTreeService.CreateDeviceAsync)
+ /// use, so the two can never describe different rules.
+ ///
+ /// The driver instance id or name to cite.
+ public static string MTConnectMultipleDevicesMessage(string driverLabel) =>
+ $"MTConnect driver '{driverLabel}' has more than one Device. MTConnect binds all tags through one agent " +
+ "client per driver instance and does not route by /raw Device, and with several Devices the Device's " +
+ "DeviceConfig (agent URI / device name) is no longer merged into the driver config, so which Device would " +
+ "supply the endpoint is ambiguous. Author one MTConnect driver per agent device, each with a single Device.";
+
+ ///
+ /// #628 (decision (b)): an MTConnect driver instance owns exactly one agent client, so a second /raw
+ /// Device under it is a genuine ambiguity — the driver ignores RawTagEntry.DeviceName, and
+ /// DriverDeviceConfigMerger only merges a Device's DeviceConfig up to the driver config when
+ /// there is exactly ONE Device. The normal single-Device layout (every driver gets a default
+ /// Device1) stays valid.
+ ///
+ private static void ValidateMTConnectSingleDevice(DraftSnapshot draft, List errors)
+ {
+ foreach (var driver in draft.DriverInstances)
+ {
+ if (!string.Equals(driver.DriverType, Core.Abstractions.DriverTypeNames.MTConnect, StringComparison.Ordinal))
+ continue;
+ var count = draft.Devices.Count(d =>
+ string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal));
+ if (count <= 1) continue;
+ errors.Add(new(MTConnectMultipleDevicesCode,
+ MTConnectMultipleDevicesMessage(driver.Name) + $" ({count} Devices found.)",
+ driver.DriverInstanceId));
+ }
+ }
+
/// WP7 Calculation-driver deploy gates. For every tag bound to a Calculation driver:
///
/// - scriptId existence — the tag's TagConfig.scriptId must be present and resolve to
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs
index 3e7d77fa..e5992fbe 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs
@@ -5,6 +5,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -615,9 +616,16 @@ public sealed class RawTreeService(IDbContextFactory dbF
await using var db = await dbFactory.CreateDbContextAsync(ct);
- if (!await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == driverInstanceId, ct))
+ var owner = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
+ if (owner is null)
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
+ // #628: MTConnect owns one agent client per driver — mirror the deploy gate
+ // (DraftValidator.ValidateMTConnectSingleDevice) so the second Device is refused where it is authored.
+ if (string.Equals(owner.DriverType, DriverTypeNames.MTConnect, StringComparison.Ordinal)
+ && await db.Devices.AnyAsync(dev => dev.DriverInstanceId == driverInstanceId, ct))
+ return new UnsMutationResult(false, DraftValidator.MTConnectMultipleDevicesMessage(owner.Name));
+
if (await db.Devices.AnyAsync(dev => dev.DriverInstanceId == driverInstanceId && dev.Name == name, ct))
return new UnsMutationResult(false, $"A device named '{name}' already exists on this driver.");
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorMTConnectDeviceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorMTConnectDeviceTests.cs
new file mode 100644
index 00000000..670ee35e
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorMTConnectDeviceTests.cs
@@ -0,0 +1,61 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
+
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
+
+///
+/// Gitea #628 (decision b): an MTConnect driver instance binds every tag through one agent client, so a
+/// second /raw Device under it is a deploy error — while the normal single-Device layout stays valid.
+///
+[Trait("Category", "Unit")]
+public sealed class DraftValidatorMTConnectDeviceTests
+{
+ private const string Code = DraftValidator.MTConnectMultipleDevicesCode;
+
+ private static DriverInstance Driver(string id, string type) => new()
+ {
+ DriverInstanceId = id, ClusterId = "c", Name = id, DriverType = type, DriverConfig = "{}",
+ };
+
+ private static Device Dev(string id, string driverId) => new()
+ {
+ DeviceId = id, DriverInstanceId = driverId, Name = id, DeviceConfig = "{}",
+ };
+
+ private static DraftSnapshot Draft(DriverInstance[] drivers, params Device[] devices) => new()
+ {
+ GenerationId = 1, ClusterId = "c", DriverInstances = drivers, Devices = devices,
+ };
+
+ [Fact]
+ public void Two_devices_under_one_MTConnect_driver_is_a_deploy_error()
+ {
+ var errors = DraftValidator.Validate(Draft([Driver("mt", "MTConnect")], Dev("d1", "mt"), Dev("d2", "mt")));
+
+ var e = errors.Where(x => x.Code == Code).ShouldHaveSingleItem();
+ e.Context.ShouldBe("mt");
+ e.Message.ShouldContain("one MTConnect driver per agent device");
+ }
+
+ [Fact]
+ public void The_normal_single_device_layout_stays_valid()
+ => DraftValidator.Validate(Draft([Driver("mt", "MTConnect")], Dev("d1", "mt")))
+ .ShouldNotContain(x => x.Code == Code);
+
+ [Fact]
+ public void A_driver_with_no_device_is_not_this_rules_concern()
+ => DraftValidator.Validate(Draft([Driver("mt", "MTConnect")]))
+ .ShouldNotContain(x => x.Code == Code);
+
+ [Fact]
+ public void Several_devices_under_a_non_MTConnect_driver_are_unaffected_and_devices_are_counted_per_driver()
+ {
+ var errors = DraftValidator.Validate(Draft(
+ [Driver("mb", "Modbus"), Driver("mt1", "MTConnect"), Driver("mt2", "MTConnect")],
+ Dev("a", "mb"), Dev("b", "mb"), Dev("c", "mt1"), Dev("d", "mt2")));
+
+ errors.ShouldNotContain(x => x.Code == Code);
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceWaveBTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceWaveBTests.cs
index 3315b79c..63febc95 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceWaveBTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceWaveBTests.cs
@@ -421,6 +421,25 @@ public sealed class RawTreeServiceWaveBTests
result.Error!.ShouldContain("already exists");
}
+ [Fact]
+ public async Task CreateDevice_refuses_a_second_device_under_an_MTConnect_driver_but_not_under_others()
+ {
+ var (service, dbName) = Seeded();
+ var mt = await service.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "mt-1", "MTConnect", "{}");
+ mt.Ok.ShouldBeTrue();
+
+ // CreateDriver auto-creates Device1, so the very next device is the ambiguous second one (#628).
+ var refused = await service.CreateDeviceAsync(mt.CreatedId!, "Device2", "{}");
+
+ refused.Ok.ShouldBeFalse();
+ refused.Error!.ShouldContain("one MTConnect driver per agent device");
+
+ // Positive control: the same call against a non-MTConnect driver still succeeds.
+ (await service.CreateDeviceAsync(RawTreeTestDb.FolderedDriverId, "Device9", "{}")).Ok.ShouldBeTrue();
+ using var db = RawTreeTestDb.CreateNamed(dbName);
+ db.Devices.Count(d => d.DriverInstanceId == mt.CreatedId).ShouldBe(1);
+ }
+
// ------------------------------------------------------------- Merged probe config (Test connect)
[Fact]