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 88714a5fd..5f9ed4f6e 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
@@ -46,6 +46,7 @@ public static class DraftValidator
errors.AddRange(SqlTagDeployChecks.UndeclaredQueryErrors(draft));
errors.AddRange(SqlTagDeployChecks.WriteBlockErrors(draft));
ValidateMTConnectSingleDevice(draft, errors);
+ errors.AddRange(HeidenhainDeployChecks.Errors(draft));
ValidateNodeAclScopes(draft, errors);
return errors;
}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs
new file mode 100644
index 000000000..0518155e8
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs
@@ -0,0 +1,92 @@
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
+
+///
+/// Heidenhain LSV2 deploy gates (lmxopcua#803). All reuse the driver's own Contracts parser, so the gate and
+/// the runtime cannot disagree.
+///
+///
+///
+/// - — the driver is read-only (no IWritable), so a ReadWrite
+/// access level would advertise a writable OPC node that can never be written. Refused, so no Heidenhain
+/// node is ever writable.
+/// - — a password field that is not blank / secret: /
+/// env: would store a controller password in the config database (replicated to every node). The
+/// message never echoes the value.
+/// - — out-of-range driver settings, or an invalid endpoint on a device
+/// that carries tags (the driver would fail to start).
+///
+///
+public static class HeidenhainDeployChecks
+{
+ /// A Heidenhain tag authored ReadWrite.
+ public const string WritableCode = "HeidenhainTagWritable";
+
+ /// A literal password in a Heidenhain DriverConfig/DeviceConfig.
+ public const string CredentialCode = "HeidenhainCredentialPersisted";
+
+ /// Invalid Heidenhain settings or endpoint.
+ public const string ConfigCode = "HeidenhainConfigInvalid";
+
+ /// Runs every Heidenhain gate.
+ /// The draft.
+ /// The errors.
+ public static IReadOnlyList Errors(DraftSnapshot draft)
+ {
+ var errors = new List();
+ foreach (var driver in draft.DriverInstances)
+ {
+ if (!string.Equals(driver.DriverType, DriverTypeNames.Heidenhain, StringComparison.Ordinal)) continue;
+ var devices = draft.Devices
+ .Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal))
+ .ToList();
+ var deviceIds = devices.Select(d => d.DeviceId).ToHashSet(StringComparer.Ordinal);
+ var tags = draft.Tags.Where(t => t.DeviceId is not null && deviceIds.Contains(t.DeviceId)).ToList();
+
+ foreach (var tag in tags.Where(t => t.AccessLevel == TagAccessLevel.ReadWrite))
+ {
+ errors.Add(new(WritableCode,
+ $"Tag '{tag.Name}' on Heidenhain driver '{driver.Name}' is ReadWrite. The Heidenhain LSV2 driver is " +
+ "read-only (only logins and read requests are ever sent); set the tag's access level to Read.",
+ tag.TagId));
+ }
+
+ HeidenhainDriverOptions options;
+ try
+ {
+ var merged = DriverDeviceConfigMerger.Merge(
+ driver.DriverConfig,
+ devices.Select(d => new DriverDeviceConfigMerger.DeviceRow(d.Name, d.DeviceConfig)).ToList(),
+ []);
+ options = HeidenhainOptionsParser.Parse(merged);
+ }
+ catch (InvalidOperationException ex)
+ {
+ errors.Add(new(ConfigCode, $"Heidenhain driver '{driver.Name}': {ex.Message}", driver.DriverInstanceId));
+ continue;
+ }
+
+ foreach (var problem in HeidenhainOptionsParser.Validate(options))
+ {
+ var code = problem.Contains("password", StringComparison.OrdinalIgnoreCase) ? CredentialCode : ConfigCode;
+ errors.Add(new(code, $"Heidenhain driver '{driver.Name}': {problem}", driver.DriverInstanceId));
+ }
+
+ foreach (var device in devices)
+ {
+ if (!tags.Any(t => string.Equals(t.DeviceId, device.DeviceId, StringComparison.Ordinal))) continue;
+ var opts = options.Devices.FirstOrDefault(o => string.Equals(o.DeviceName, device.Name, StringComparison.Ordinal));
+ var problems = opts is null ? ["Device has no endpoint."] : HeidenhainOptionsParser.ValidateDevice(opts);
+ foreach (var p in problems)
+ errors.Add(new(ConfigCode, $"Heidenhain device '{device.Name}': {p}", device.DeviceId));
+ }
+ }
+
+ return errors;
+ }
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj
index e153ea358..3d1bf2deb 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj
@@ -47,6 +47,8 @@
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs
index f00f58e59..50b847df0 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs
@@ -134,16 +134,8 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
{
var devices = _options.Devices ?? [];
_skipped.BeginRebuild(_options.RawTags.Count);
- var deviceErrors = new List();
var perDevice = new Dictionary>();
- foreach (var d in devices)
- {
- var errs = HeidenhainOptionsParser.ValidateDevice(d);
- if (errs.Count > 0) deviceErrors.Add($"Device '{d.DeviceName ?? d.Host}': {string.Join(" ", errs)}");
- else perDevice[d] = [];
- }
-
- if (deviceErrors.Count > 0) throw new InvalidOperationException(string.Join("; ", deviceErrors));
+ foreach (var d in devices) perDevice[d] = [];
foreach (var raw in _options.RawTags)
{
@@ -171,6 +163,15 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
list.Add(def);
}
+ // A control is validated only when it carries tags: a freshly created /raw Device has an empty
+ // DeviceConfig, and must not fault the whole driver (or the deploy) before it is configured.
+ var deviceErrors = perDevice.Where(kv => kv.Value.Count > 0)
+ .Select(kv => (kv.Key, Errors: HeidenhainOptionsParser.ValidateDevice(kv.Key)))
+ .Where(x => x.Errors.Count > 0)
+ .Select(x => $"Device '{x.Key.DeviceName ?? x.Key.Host}': {string.Join(" ", x.Errors)}")
+ .ToList();
+ if (deviceErrors.Count > 0) throw new InvalidOperationException(string.Join("; ", deviceErrors));
+
_skipped.Commit();
var runtimes = new List();
var byPath = new Dictionary(StringComparer.Ordinal);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs
index d49bbc219..947f27084 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs
@@ -65,6 +65,7 @@ public static class EquipmentTagConfigInspector
[DriverTypeNames.MTConnect] = MTConnectTagDefinitionFactory.Inspect,
[DriverTypeNames.Calculation] = CalculationTagDefinitionFactory.Inspect,
[DriverTypeNames.OpcUaClient] = OpcUaClientTagDefinitionFactory.Inspect,
+ [DriverTypeNames.Heidenhain] = Driver.Heidenhain.HeidenhainTagDefinitionFactory.Inspect,
};
/// Inspects a tag's TagConfig for the given driver type. Returns the driver's warnings,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
index a1b2f9ab8..33e268cb2 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
@@ -57,6 +57,7 @@
used to pull in transitively left with NamespaceMap, to Driver.OpcUaClient.Session. -->
+
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHeidenhainTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHeidenhainTests.cs
new file mode 100644
index 000000000..a71303447
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHeidenhainTests.cs
@@ -0,0 +1,78 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
+
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
+
+/// lmxopcua#803: the Heidenhain read-only, credential and endpoint deploy gates.
+[Trait("Category", "Unit")]
+public sealed class DraftValidatorHeidenhainTests
+{
+ private static DriverInstance Driver(string config = "{}", string type = "Heidenhain") => new()
+ {
+ DriverInstanceId = "hh", ClusterId = "c", Name = "hh", DriverType = type, DriverConfig = config,
+ };
+
+ private static Device Dev(string config = """{"Host":"10.0.0.5"}""") => new()
+ {
+ DeviceId = "d1", DriverInstanceId = "hh", Name = "dev1", DeviceConfig = config,
+ };
+
+ private static Tag T(TagAccessLevel access) => new()
+ {
+ TagId = "t1", DeviceId = "d1", Name = "ExecState", DataType = "Int32", AccessLevel = access,
+ TagConfig = """{"address":"Status/ExecutionState"}""",
+ };
+
+ private static IReadOnlyList Validate(DriverInstance d, Device dev, params Tag[] tags) =>
+ DraftValidator.Validate(new DraftSnapshot
+ {
+ GenerationId = 1, ClusterId = "c", DriverInstances = [d], Devices = [dev], Tags = tags,
+ });
+
+ [Fact]
+ public void A_read_only_tag_on_a_configured_control_is_valid()
+ => Validate(Driver(), Dev(), T(TagAccessLevel.Read)).ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
+
+ [Fact]
+ public void A_ReadWrite_tag_is_refused_so_no_node_is_ever_writable()
+ {
+ var e = Validate(Driver(), Dev(), T(TagAccessLevel.ReadWrite)).Single(x => x.Code == HeidenhainDeployChecks.WritableCode);
+ e.Context.ShouldBe("t1");
+ e.Message.ShouldContain("read-only");
+ }
+
+ [Theory]
+ [InlineData("""{"Access":{"InspectPassword":"hunter2"}}""", "{\"Host\":\"10.0.0.5\"}")]
+ [InlineData("{}", """{"Host":"10.0.0.5","DncPassword":"hunter2"}""")]
+ public void A_literal_password_is_refused_without_echoing_it(string driverConfig, string deviceConfig)
+ {
+ var errors = Validate(Driver(driverConfig), Dev(deviceConfig), T(TagAccessLevel.Read));
+ errors.ShouldContain(e => e.Code == HeidenhainDeployChecks.CredentialCode);
+ errors.ShouldAllBe(e => !e.Message.Contains("hunter2"));
+ }
+
+ [Fact]
+ public void Secret_and_env_references_are_accepted()
+ => Validate(Driver("""{"Access":{"InspectPassword":"secret:lsv2/inspect","DncPassword":"env:LSV2_DNC"}}"""), Dev(), T(TagAccessLevel.Read))
+ .ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
+
+ [Fact]
+ public void A_device_with_tags_but_no_endpoint_is_refused_while_an_empty_device_is_not()
+ {
+ Validate(Driver(), Dev("{}"), T(TagAccessLevel.Read)).ShouldContain(e => e.Code == HeidenhainDeployChecks.ConfigCode && e.Context == "d1");
+ Validate(Driver(), Dev("{}")).ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void Out_of_range_settings_are_refused()
+ => Validate(Driver("""{"Polling":{"MaxAlarms":999}}"""), Dev(), T(TagAccessLevel.Read))
+ .ShouldContain(e => e.Code == HeidenhainDeployChecks.ConfigCode);
+
+ [Fact]
+ public void Other_drivers_are_unaffected()
+ => Validate(Driver("""{"Access":{"InspectPassword":"x"}}""", type: "Modbus"), Dev(), T(TagAccessLevel.ReadWrite))
+ .ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
+}