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]