feat(mtconnect): one agent client per driver — a second /raw Device is a deploy error (#628)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+18
-13
@@ -410,14 +410,24 @@ for anything beyond values-and-conditions.
|
|||||||
`GetHostStatuses()` fresh, and the result rides `DriverHealthChanged` to
|
`GetHostStatuses()` fresh, and the result rides `DriverHealthChanged` to
|
||||||
the AdminUI `/hosts` page (no SQL; the `DriverHostStatus` table was
|
the AdminUI `/hosts` page (no SQL; the `DriverHostStatus` table was
|
||||||
dropped). See `docs/Telemetry.md`.
|
dropped). See `docs/Telemetry.md`.
|
||||||
2. **`RawTagEntry.DeviceName` is accepted on the wire shape but ignored for
|
2. **One MTConnect driver instance = one Agent client = at most one `/raw`
|
||||||
routing.** One driver instance owns exactly one Agent client scoped by
|
Device (#628, decision (b)).** The driver never reads
|
||||||
`MTConnectDriverOptions.DeviceName` (the top-level config key); the
|
`RawTagEntry.DeviceName`; every raw tag binds through the single Agent client
|
||||||
per-tag `RawTagEntry.DeviceName` field the v3 raw-tag identity carries is
|
scoped by `MTConnectDriverOptions.DeviceName`. The v3 raw tree always has a
|
||||||
never read by the driver. Two `/raw` Devices authored under one MTConnect
|
Device level (a new driver auto-creates `Device1`), and that single-Device
|
||||||
driver instance both resolve to the same one Agent connection. Real
|
layout is the normal, valid shape — its `DeviceConfig` is merged up into the
|
||||||
per-device routing (a client per device) would be a design change, not a
|
driver config by `DriverDeviceConfigMerger` (this is how the endpoint lands).
|
||||||
bug fix.
|
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**,
|
**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
|
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
|
So `MTConnectDriverOptions.DeviceName` does double duty — request scope for
|
||||||
reads/browse, fallback write target for writes.
|
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
|
3. **Authoring only *warns* when a declared coercion type cannot hold the
|
||||||
inferred one (#613).** The AdminUI tag editor (`MTConnectTagConfigModel.Warnings()`)
|
inferred one (#613).** The AdminUI tag editor (`MTConnectTagConfigModel.Warnings()`)
|
||||||
compares the declared type with `MTConnectDataTypeInference` over the
|
compares the declared type with `MTConnectDataTypeInference` over the
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public static class DraftValidator
|
|||||||
ValidateEquipReferenceResolution(draft, errors);
|
ValidateEquipReferenceResolution(draft, errors);
|
||||||
ValidateCalculationTags(draft, errors);
|
ValidateCalculationTags(draft, errors);
|
||||||
ValidateSqlConnectionStringNotPersisted(draft, errors);
|
ValidateSqlConnectionStringNotPersisted(draft, errors);
|
||||||
|
ValidateMTConnectSingleDevice(draft, errors);
|
||||||
ValidateNodeAclScopes(draft, errors);
|
ValidateNodeAclScopes(draft, errors);
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
@@ -343,6 +344,42 @@ public static class DraftValidator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Error code for an MTConnect driver instance that has more than one <c>/raw</c> Device (#628).</summary>
|
||||||
|
public const string MTConnectMultipleDevicesCode = "MTConnectMultipleDevices";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one message both the deploy gate and the authoring-time guard (<c>RawTreeService.CreateDeviceAsync</c>)
|
||||||
|
/// use, so the two can never describe different rules.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="driverLabel">The driver instance id or name to cite.</param>
|
||||||
|
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.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #628 (decision (b)): an MTConnect driver instance owns exactly one agent client, so a second <c>/raw</c>
|
||||||
|
/// Device under it is a genuine ambiguity — the driver ignores <c>RawTagEntry.DeviceName</c>, and
|
||||||
|
/// <c>DriverDeviceConfigMerger</c> only merges a Device's <c>DeviceConfig</c> up to the driver config when
|
||||||
|
/// there is exactly ONE Device. The normal single-Device layout (every driver gets a default
|
||||||
|
/// <c>Device1</c>) stays valid.
|
||||||
|
/// </summary>
|
||||||
|
private static void ValidateMTConnectSingleDevice(DraftSnapshot draft, List<ValidationError> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
|
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||||
@@ -615,9 +616,16 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
|||||||
|
|
||||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
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.");
|
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))
|
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.");
|
return new UnsMutationResult(false, $"A device named '{name}' already exists on this driver.");
|
||||||
|
|
||||||
|
|||||||
+61
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #628 (decision b): an MTConnect driver instance binds every tag through one agent client, so a
|
||||||
|
/// second <c>/raw</c> Device under it is a deploy error — while the normal single-Device layout stays valid.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -421,6 +421,25 @@ public sealed class RawTreeServiceWaveBTests
|
|||||||
result.Error!.ShouldContain("already exists");
|
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)
|
// ------------------------------------------------------------- Merged probe config (Test connect)
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user