diff --git a/docs/Extrusion.md b/docs/Extrusion.md
index 6b0ffa8cba..33a367038b 100644
--- a/docs/Extrusion.md
+++ b/docs/Extrusion.md
@@ -181,6 +181,12 @@ Common controllers are supplied through `ExtrusionDeviceOptions.Common`:
The common package owns their protocols. Selecting a command-bearing capability
without its authority fails creation.
+A line's MaterialList is bound to `ExtrusionDeviceOptions.Common.Materials`.
+OPC 40083 declares its `DensityUnit` without a value, so a bound catalogue also
+needs `Common.MaterialDensityUnit`, the unit every material density is given in;
+creation fails without it. An unbound list publishes the configured unit, or a
+null `DensityUnit` when none is configured.
+
Observation publication is independent of an awaited hardware command, allowing
an authoritative coordinator to deliver its committed state before replying to
a forwarded command.
diff --git a/docs/Vision.md b/docs/Vision.md
index b9258e666f..881fef1a8d 100644
--- a/docs/Vision.md
+++ b/docs/Vision.md
@@ -844,6 +844,15 @@ modality (`WithModality`), and the nested builders — `WithOptics(...)`,
`AddExtrinsicCalibration(...)`, `AddStreamEndpoint(...)`,
`AddClipEndpoint(...)`, `UseMediaProvider(...)`.
+The model declares no value for the mandatory properties of sensors,
+frames, calibrations, media endpoints and pipelines. The builders publish one
+for the following when the configuration leaves them unset: the builder's
+reality kind (`Physical`), modality (`Area2D`) and frame role (`Other`);
+`Inactive` for endpoint and pipeline `State`, `None` for `Authentication`,
+`false` for `SecureTransport`; for calibrations an unknown `PerformedAt` (the
+null `DateTime`) and `Valid` `true`; and the null `NodeId` for the
+`SourceFrame`, `TargetFrame`, `Sensor` and `Deployment` references.
+
`WithFrameId(frameId)` also adds a `MountedOn` reference to the
`CoordinateFrameType` instance with the matching `FrameId` when one has
been registered under `Vision/Frames`. This is the recommended way to
diff --git a/src/Opc.Ua.Extrusion.Server/ExtrusionCommonProviders.cs b/src/Opc.Ua.Extrusion.Server/ExtrusionCommonProviders.cs
index 08534021bf..939b5fbe22 100644
--- a/src/Opc.Ua.Extrusion.Server/ExtrusionCommonProviders.cs
+++ b/src/Opc.Ua.Extrusion.Server/ExtrusionCommonProviders.cs
@@ -78,6 +78,13 @@ namespace Opc.Ua.Extrusion.Server
///
public IMaterialListProvider? Materials { get; set; }
+ ///
+ /// Gets or sets the engineering unit the MaterialList publishes as its DensityUnit and in which
+ /// every material density is given. OPC 40083 declares DensityUnit without a value, so a list
+ /// bound to or with selected mutation methods requires this unit.
+ ///
+ public EUInformation? MaterialDensityUnit { get; set; }
+
///
/// Gets or sets the instance-specific production-dataset persistence/activation authority.
///
diff --git a/src/Opc.Ua.Extrusion.Server/ExtrusionDevice.Common.cs b/src/Opc.Ua.Extrusion.Server/ExtrusionDevice.Common.cs
index 86a82ed895..f809634dc2 100644
--- a/src/Opc.Ua.Extrusion.Server/ExtrusionDevice.Common.cs
+++ b/src/Opc.Ua.Extrusion.Server/ExtrusionDevice.Common.cs
@@ -104,12 +104,28 @@ namespace Opc.Ua.Extrusion.Server
?? throw MissingProvider(nameof(m_common.MachineConfiguration)),
RequireAccessPolicy()));
break;
- case MaterialListState materials when m_common.Materials != null ||
- materials.AddMaterial != null || materials.RemoveMaterialById != null:
- m_commonBindings.Add(new MaterialListBinding(
- m_runtime.Manager, materials,
- m_common.Materials ?? throw MissingProvider(nameof(m_common.Materials)),
- RequireAccessPolicy()));
+ case MaterialListState materials:
+ if (materials.DensityUnit != null && m_common.MaterialDensityUnit != null)
+ {
+ SetProtocolValue(materials.DensityUnit, new DataValue(
+ Variant.FromStructure((EUInformation)m_common.MaterialDensityUnit.Clone()),
+ StatusCodes.Good, DateTimeUtc.Now));
+ }
+ if (m_common.Materials != null ||
+ materials.AddMaterial != null || materials.RemoveMaterialById != null)
+ {
+ IMaterialListProvider materialProvider =
+ m_common.Materials ?? throw MissingProvider(nameof(m_common.Materials));
+ if (materials.DensityUnit?.Value == null)
+ {
+ throw ServiceResultException.Create(
+ StatusCodes.BadConfigurationError,
+ "The selected material catalogue needs an explicit '{0}'.",
+ nameof(m_common.MaterialDensityUnit));
+ }
+ m_commonBindings.Add(new MaterialListBinding(
+ m_runtime.Manager, materials, materialProvider, RequireAccessPolicy()));
+ }
break;
case ProductionDatasetManagementState datasets:
ProductionDatasetBinding datasetBinding = await ProductionDatasetBinding.CreateAsync(
diff --git a/src/Opc.Ua.Robotics.Server/Intent/IntentControllerHost.cs b/src/Opc.Ua.Robotics.Server/Intent/IntentControllerHost.cs
index 9d6ccea764..e8b472cfe8 100644
--- a/src/Opc.Ua.Robotics.Server/Intent/IntentControllerHost.cs
+++ b/src/Opc.Ua.Robotics.Server/Intent/IntentControllerHost.cs
@@ -2552,6 +2552,12 @@ namespace Opc.Ua.RobotIntent.Server
SetValue(node.MissionId, entry.MissionId);
SetValue(node.Progress, -1.0);
SetValue(node.QueuePosition, (uint)0);
+ // The model declares Result and CurrentPose without a Value, so they
+ // start out null; publish empty structures until the executor
+ // reports a pose and the intent reaches a terminal result, so
+ // clients always read a value of the declared DataType.
+ SetValue(node.CurrentPose, new Pose3DDataType());
+ SetValue(node.Result, new IntentResultDataType());
SetValue(node.Deletable, true);
SetValue(node.AutoDelete, false);
SetValue(node.RecycleCount, 0);
diff --git a/src/Opc.Ua.Vision.Server/Builders/VisionNodeBuilder.cs b/src/Opc.Ua.Vision.Server/Builders/VisionNodeBuilder.cs
index 16a707703a..22e1500634 100644
--- a/src/Opc.Ua.Vision.Server/Builders/VisionNodeBuilder.cs
+++ b/src/Opc.Ua.Vision.Server/Builders/VisionNodeBuilder.cs
@@ -163,6 +163,7 @@ namespace Opc.Ua.Vision.Server.Builders
pipelinesFolder,
qualifiedName);
pipeline.ReferenceTypeId = ReferenceTypeIds.Organizes;
+ VisionMandatoryProperties.Initialize(m_context.Context, pipeline);
var builder = new VisionPipelineBuilder(m_context, m_registry, m_dispatcher, pipeline, browseName);
configure(builder);
builder.Finalize(pipelinesFolder);
@@ -288,6 +289,7 @@ namespace Opc.Ua.Vision.Server.Builders
m_frame.CreateOrReplaceParentFrame(m_context.Context, null);
m_frame.ParentFrame!.Value = parentNodeId;
}
+ m_frame.CreateOrReplaceRole(m_context.Context, null).Value = m_role;
parent.AddChild(m_frame);
var registration = new FrameRegistration(
m_browseName,
@@ -465,6 +467,7 @@ namespace Opc.Ua.Vision.Server.Builders
IntrinsicCalibrationState calibration = BuildContext.Context
.CreateInstanceOfIntrinsicCalibrationType(calibrations, qualifiedName);
calibration.ReferenceTypeId = ReferenceTypeIds.Organizes;
+ VisionMandatoryProperties.Initialize(BuildContext.Context, calibration);
var builder = new VisionIntrinsicCalibrationBuilder(BuildContext, calibration);
configure(builder);
calibrations.AddChild(calibration);
@@ -492,6 +495,7 @@ namespace Opc.Ua.Vision.Server.Builders
ExtrinsicCalibrationState calibration = BuildContext.Context
.CreateInstanceOfExtrinsicCalibrationType(calibrations, qualifiedName);
calibration.ReferenceTypeId = ReferenceTypeIds.Organizes;
+ VisionMandatoryProperties.Initialize(BuildContext.Context, calibration);
var builder = new VisionExtrinsicCalibrationBuilder(BuildContext, calibration);
configure(builder);
calibrations.AddChild(calibration);
@@ -523,6 +527,7 @@ namespace Opc.Ua.Vision.Server.Builders
endpoints,
qualifiedName);
endpoint.ReferenceTypeId = ReferenceTypeIds.Organizes;
+ VisionMandatoryProperties.Initialize(BuildContext.Context, endpoint);
var builder = new VisionStreamEndpointBuilder(BuildContext, endpoint);
configure(builder);
endpoints.AddChild(endpoint);
@@ -550,6 +555,7 @@ namespace Opc.Ua.Vision.Server.Builders
endpoints,
qualifiedName);
endpoint.ReferenceTypeId = ReferenceTypeIds.Organizes;
+ VisionMandatoryProperties.Initialize(BuildContext.Context, endpoint);
var builder = new VisionClipEndpointBuilder(BuildContext, endpoint);
configure(builder);
endpoints.AddChild(endpoint);
@@ -566,6 +572,8 @@ namespace Opc.Ua.Vision.Server.Builders
internal void Finalize(FolderState parent)
{
+ Sensor.CreateOrReplaceRealityKind(BuildContext.Context, null).Value = m_realityKind;
+ Sensor.CreateOrReplaceModality(BuildContext.Context, null).Value = m_modality;
parent.AddChild(Sensor);
HashSet facets = ComputeFacets();
var registration = new SensorRegistration(
@@ -1375,4 +1383,49 @@ namespace Opc.Ua.Vision.Server.Builders
private IVisionFeedbackSink? m_feedbackSink;
private bool m_inferenceOnServer = true;
}
+
+ ///
+ /// Publishes a value for the mandatory Perception properties that no
+ /// builder method configures, and the null NodeId for the references a
+ /// configuration may leave unset. The model declares no Value for them,
+ /// so a created instance starts with a null Value.
+ ///
+ internal static class VisionMandatoryProperties
+ {
+ ///
+ /// The Server keeps no calibration time or staleness information:
+ /// PerformedAt is unknown and the calibration is not known to be stale.
+ ///
+ public static void Initialize(ISystemContext context, VisionCalibrationState calibration)
+ {
+ calibration.CreateOrReplacePerformedAt(context, null).Value = DateTimeUtc.MinValue;
+ calibration.CreateOrReplaceValid(context, null).Value = true;
+ if (calibration is ExtrinsicCalibrationState extrinsic)
+ {
+ extrinsic.CreateOrReplaceSourceFrame(context, null).Value = NodeId.Null;
+ extrinsic.CreateOrReplaceTargetFrame(context, null).Value = NodeId.Null;
+ }
+ }
+
+ ///
+ /// The Server keeps no endpoint state and does not authenticate media access.
+ ///
+ public static void Initialize(ISystemContext context, MediaEndpointState endpoint)
+ {
+ endpoint.CreateOrReplaceState(context, null).Value = VisionEndpointStateEnum.Inactive;
+ endpoint.CreateOrReplaceAuthentication(context, null).Value = VisionEndpointAuthenticationEnum.None;
+ endpoint.CreateOrReplaceSecureTransport(context, null).Value = false;
+ }
+
+ ///
+ /// The Server keeps no pipeline state; a pipeline without a sensor or a
+ /// deployment references the null NodeId.
+ ///
+ public static void Initialize(ISystemContext context, InferencePipelineState pipeline)
+ {
+ pipeline.CreateOrReplaceState(context, null).Value = VisionEndpointStateEnum.Inactive;
+ pipeline.CreateOrReplaceSensor(context, null).Value = NodeId.Null;
+ pipeline.CreateOrReplaceDeployment(context, null).Value = NodeId.Null;
+ }
+ }
}
diff --git a/tests/Opc.Ua.Extrusion.Tests/ExtrusionFixture.cs b/tests/Opc.Ua.Extrusion.Tests/ExtrusionFixture.cs
index b5677b0f85..f2d00f16c9 100644
--- a/tests/Opc.Ua.Extrusion.Tests/ExtrusionFixture.cs
+++ b/tests/Opc.Ua.Extrusion.Tests/ExtrusionFixture.cs
@@ -128,11 +128,23 @@ namespace Opc.Ua.Extrusion.Tests
if (all)
{
options.Common.Materials = new Mock().Object;
+ options.Common.MaterialDensityUnit = KilogramPerCubicMetre;
options.Features.IncludeAll();
}
return options;
}
+ ///
+ /// Gets the UNECE kilogram per cubic metre (KMQ) a test material catalogue publishes as its DensityUnit.
+ ///
+ internal static EUInformation KilogramPerCubicMetre => new()
+ {
+ NamespaceUri = "http://www.opcfoundation.org/UA/units/un/cefact",
+ UnitId = 4934993,
+ DisplayName = new LocalizedText("en", "kg/m³"),
+ Description = new LocalizedText("en", "kilogram per cubic metre")
+ };
+
///
/// Exercises every concrete public constructor without creating an abstract ExtrusionDevice.
///
diff --git a/tests/Opc.Ua.Extrusion.Tests/ExtrusionModelCoverageTests.cs b/tests/Opc.Ua.Extrusion.Tests/ExtrusionModelCoverageTests.cs
index f7d2ca9404..d2963ec152 100644
--- a/tests/Opc.Ua.Extrusion.Tests/ExtrusionModelCoverageTests.cs
+++ b/tests/Opc.Ua.Extrusion.Tests/ExtrusionModelCoverageTests.cs
@@ -202,6 +202,56 @@ namespace Opc.Ua.Extrusion.Tests
Assert.That(failure.StatusCode, Is.EqualTo((StatusCode)StatusCodes.BadConfigurationError));
}
+ ///
+ /// OPC 40083 declares the MaterialList DensityUnit without a value: an unbound list keeps a null
+ /// unit, and a bound catalogue publishes the configured unit its densities are given in.
+ ///
+ [Test]
+ public async Task MaterialListPublishesOnlyTheConfiguredDensityUnitAsync()
+ {
+ ExtrusionDeviceState unbound = await ExtrusionFixture.CreateAsync(
+ m_fixture.Runtime, 2, ExtrusionFixture.Options("density-unit-unbound")).ConfigureAwait(false);
+ var unboundLine = (Line.ExtrusionLine_InterfaceState)unbound;
+
+ ExtrusionDeviceOptions options = ExtrusionFixture.Options("density-unit-bound", all: true);
+ ExtrusionDeviceState bound = await ExtrusionFixture.CreateAsync(m_fixture.Runtime, 2, options)
+ .ConfigureAwait(false);
+ var boundLine = (Line.ExtrusionLine_InterfaceState)bound;
+ (ServiceResult result, DataValue read) = await boundLine.MaterialList!.DensityUnit!.ReadAttributeAsync(
+ m_fixture.Runtime.SystemContext, Attributes.Value, NumericRange.Null,
+ QualifiedName.Null, new DataValue()).ConfigureAwait(false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(unboundLine.MaterialList!.DensityUnit!.Value, Is.Null);
+ Assert.That(ServiceResult.IsBad(result), Is.False);
+ Assert.That(read.StatusCode, Is.EqualTo((StatusCode)StatusCodes.Good));
+ Assert.That(read.WrappedValue.TryGetStructure(out EUInformation? unit), Is.True);
+ Assert.That(unit?.UnitId, Is.EqualTo(ExtrusionFixture.KilogramPerCubicMetre.UnitId));
+ Assert.That(boundLine.MaterialList.DensityUnit.Value,
+ Is.Not.SameAs(options.Common.MaterialDensityUnit));
+ });
+ }
+
+ ///
+ /// A bound material catalogue without a configured DensityUnit is rejected during creation.
+ ///
+ [Test]
+ public void BoundMaterialListWithoutDensityUnitIsRejected()
+ {
+ ExtrusionDeviceOptions options = ExtrusionFixture.Options("density-unit-missing", all: true);
+ options.Common.MaterialDensityUnit = null;
+
+ ServiceResultException failure = Assert.ThrowsAsync(async () =>
+ await ExtrusionFixture.CreateAsync(m_fixture.Runtime, 2, options).ConfigureAwait(false))!;
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(failure.StatusCode, Is.EqualTo((StatusCode)StatusCodes.BadConfigurationError));
+ Assert.That(failure.Message, Does.Contain(nameof(ExtrusionCommonProviders.MaterialDensityUnit)));
+ });
+ }
+
///
/// Serializes concurrent creation across the duplicate check and registration.
///
diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/ModelDependencyGeneratorTests.cs b/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/ModelDependencyGeneratorTests.cs
index 4feed05694..7735c85f6c 100644
--- a/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/ModelDependencyGeneratorTests.cs
+++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/ModelDependencyGeneratorTests.cs
@@ -422,6 +422,56 @@ namespace Opc.Ua.SourceGeneration.Generator.Tests
});
}
+ [Test]
+ public void EmitVariableSerializesAbsentNodeSetValue()
+ {
+ Namespace target = ConfigureSelf();
+ var withoutValue = new VariableDesign
+ {
+ BrowseName = "RatedFlow",
+ SymbolicId = new XmlQualifiedName("PumpType_RatedFlow", TestUri),
+ SymbolicName = new XmlQualifiedName("RatedFlow", TestUri),
+ TypeDefinition = new XmlQualifiedName("PropertyType", Types.Namespaces.OpcUa),
+ DataType = new XmlQualifiedName("Double", Types.Namespaces.OpcUa),
+ NodeSetValueIsNull = true
+ };
+ var withDataTypeDefault = new VariableDesign
+ {
+ BrowseName = "Count",
+ SymbolicId = new XmlQualifiedName("PumpType_Count", TestUri),
+ SymbolicName = new XmlQualifiedName("Count", TestUri),
+ TypeDefinition = new XmlQualifiedName("PropertyType", Types.Namespaces.OpcUa),
+ DataType = new XmlQualifiedName("Int32", Types.Namespaces.OpcUa)
+ };
+ var objectType = new ObjectTypeDesign
+ {
+ ClassName = "Pump",
+ SymbolicId = new XmlQualifiedName("PumpType", TestUri),
+ SymbolicName = new XmlQualifiedName("PumpType", TestUri),
+ HasChildren = true,
+ Children = new ListOfChildren { Items = [withoutValue, withDataTypeDefault] }
+ };
+ m_mockModelDesign.Setup(m => m.TargetNamespace).Returns(target);
+ m_mockModelDesign.Setup(m => m.Nodes).Returns([objectType]);
+
+ var generator = new ModelDependencyGenerator(BuildContext());
+ generator.Emit();
+
+ ModelDependencyV1 payload = ReadSelfPayload();
+ IReadOnlyList children = payload.Nodes
+ .Single(node => node.SymbolicName == "PumpType")
+ .Children;
+ Assert.Multiple(() =>
+ {
+ Assert.That(
+ children.Single(child => child.SymbolicName == "RatedFlow").NodeSetValueIsNull,
+ Is.True);
+ Assert.That(
+ children.Single(child => child.SymbolicName == "Count").NodeSetValueIsNull,
+ Is.False);
+ });
+ }
+
[Test]
public void PayloadWithoutMethodIdentityRemainsReadable()
{
diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/InheritedValueAbsent.NodeSet2.xml b/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/InheritedValueAbsent.NodeSet2.xml
new file mode 100644
index 0000000000..d550d6abb9
--- /dev/null
+++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/InheritedValueAbsent.NodeSet2.xml
@@ -0,0 +1,37 @@
+
+
+
+
+ http://test.org/UA/InheritedValueAbsent/
+ http://opcfoundation.org/UA/DI/
+
+
+
+
+
+
+
+
+ i=40
+ i=45
+
+
+ InheritingPumpType
+
+ ns=2;i=1002
+
+
+
+ InheritingPump
+
+ ns=1;i=1000
+
+
+
diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/Schema/NodeSetValueAbsentTests.cs b/tests/Opc.Ua.SourceGeneration.Core.Tests/Schema/NodeSetValueAbsentTests.cs
new file mode 100644
index 0000000000..c7c7055f32
--- /dev/null
+++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/Schema/NodeSetValueAbsentTests.cs
@@ -0,0 +1,334 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Logging;
+using NUnit.Framework;
+using Opc.Ua.SourceGeneration;
+using Opc.Ua.SourceGeneration.Dependency;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.Schema.Model.Tests
+{
+ ///
+ /// A NodeSet2 Variable or VariableType without a Value element has a
+ /// null Value (OPC 10000-6 F.5). The generator used to initialize such
+ /// a node with the default value of its DataType ((int)0, (double)0),
+ /// so a server built from the NodeSet reported a value the model never
+ /// declared.
+ ///
+ [TestFixture]
+ [Category("ModelDesign")]
+ [SetCulture("en-us")]
+ [SetUICulture("en-us")]
+ public class NodeSetValueAbsentTests
+ {
+ private const string NullValue = "baseState.WrappedValue = global::Opc.Ua.Variant.Null;";
+
+ [TestCase("CreatePump1_RevisionCounter", "(int)0")]
+ [TestCase("CreatePump1_RatedFlow", "(double)0")]
+ [TestCase("CreatePumpType_RatedFlow", "(double)0")]
+ public void VariableWithoutValueIsNull(string method, string dataTypeDefault)
+ {
+ string body = ExtractMethodBody(GenerateDeviceSetPump(), method);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(body, Does.Contain(NullValue));
+ Assert.That(body, Does.Not.Contain(dataTypeDefault));
+ });
+ }
+
+ [Test]
+ public void VariableWithValueKeepsIt()
+ {
+ string body = ExtractMethodBody(GenerateDeviceSetPump(), "CreatePump1_SerialNumber");
+
+ Assert.That(
+ body,
+ Does.Contain("baseState.WrappedValue = global::Opc.Ua.Variant.From(\"SN-0001\");"));
+ }
+
+ [Test]
+ public void VariableTypeWithoutValueIsNull()
+ {
+ string body = ExtractMethodBody(GenerateDeviceSetPump(), "CreateFlowVariableType");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(body, Does.Contain(NullValue));
+ Assert.That(body, Does.Not.Contain("(double)0"));
+ });
+ }
+
+ [Test]
+ public void InstanceOfVariableTypeWithoutValueIsNull()
+ {
+ string body = ExtractMethodBody(GenerateDeviceSetPump(), "CreateInstanceOfFlowVariableType");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(body, Does.Contain(NullValue));
+ Assert.That(body, Does.Not.Contain("(double)0"));
+ });
+ }
+
+ [Test]
+ public void SubtypeWithoutValueDoesNotInheritTheValueOfItsSupertype()
+ {
+ string generated = GenerateDeviceSetPump();
+ string supertype = ExtractMethodBody(generated, "CreateInstanceOfRatedFlowVariableType");
+ string subtype = ExtractMethodBody(generated, "CreateInstanceOfUnratedFlowVariableType");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(supertype, Does.Contain("42.5"), "the supertype keeps its declared Value");
+ Assert.That(subtype, Does.Contain(NullValue));
+ Assert.That(subtype, Does.Not.Contain("42.5"));
+ Assert.That(subtype, Does.Not.Contain("(double)0"));
+ });
+ }
+
+ [TestCase("CreateUnsetRatedFlow")]
+ [TestCase("CreateRatedFlowMeter_UnsetFlow")]
+ [TestCase("CreateRatedFlowMeter1_RatedFlow")]
+ public void VariableWithoutValueDoesNotInheritTheValueOfItsVariableType(string method)
+ {
+ string body = ExtractMethodBody(GenerateDeviceSetPump(), method);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(body, Does.Contain(NullValue));
+ Assert.That(body, Does.Not.Contain("42.5"));
+ Assert.That(body, Does.Not.Contain("12.5"));
+ Assert.That(body, Does.Not.Contain("(double)0"));
+ });
+ }
+
+ [Test]
+ public void VariableWithValueOverridesTheValueOfItsVariableType()
+ {
+ string body = ExtractMethodBody(GenerateDeviceSetPump(), "CreateRatedFlowMeter_SetFlow");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(body, Does.Contain("7.25"));
+ Assert.That(body, Does.Not.Contain("42.5"));
+ });
+ }
+
+ [Test]
+ public void DependencyPayloadCarriesTheAbsentValue()
+ {
+ var dependency = new ModelDependencyV1 { ModelUri = "http://test.org/UA/ValueAbsent/" };
+ dependency.Nodes.Add(new DependencyNode
+ {
+ SymbolicName = "PumpType",
+ SymbolicNamespace = "http://test.org/UA/ValueAbsent/",
+ ClassName = "PumpType",
+ Kind = DependencyNodeKind.ObjectType,
+ Children =
+ [
+ new DependencyChild
+ {
+ BrowseName = "RatedFlow",
+ SymbolicName = "RatedFlow",
+ InstanceKind = 3,
+ NodeSetValueIsNull = true
+ },
+ new DependencyChild
+ {
+ BrowseName = "SerialNumber",
+ SymbolicName = "SerialNumber",
+ InstanceKind = 3,
+ DefaultValueXml = "SN"
+ }
+ ]
+ });
+
+ ModelDependencyV1? decoded = ModelDependencyV1.FromBase64Payload(dependency.ToBase64Payload());
+
+ Assert.That(decoded, Is.Not.Null);
+ IReadOnlyList children = decoded!.Nodes.Single().Children;
+ Assert.Multiple(() =>
+ {
+ Assert.That(children[0].NodeSetValueIsNull, Is.True);
+ Assert.That(children[1].NodeSetValueIsNull, Is.False);
+ Assert.That(children[1].DefaultValueXml, Does.Contain("SN"));
+ });
+ }
+
+ private static string GenerateDeviceSetPump()
+ {
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error);
+ using var fileSystem = new VirtualFileSystem();
+ string resources = Path.Combine(Directory.GetCurrentDirectory(), "Resources");
+ const string flowTypePath = "memory://FlowVariableType.NodeSet2.xml";
+ fileSystem.Add(flowTypePath, Encoding.UTF8.GetBytes(FlowVariableTypeNodeSet));
+ var nodesets = new NodesetFileCollection(
+ [
+ (Path.Combine(resources, "Opc.Ua.Di.NodeSet2.xml"), new NodesetFileOptions()),
+ (Path.Combine(resources, "DeviceSetPump.NodeSet2.xml"), new NodesetFileOptions()),
+ (flowTypePath, new NodesetFileOptions())
+ ],
+ [],
+ fileSystem,
+ telemetry);
+ nodesets.GenerateCode(
+ fileSystem,
+ string.Empty,
+ telemetry,
+ new GeneratorOptions { OmitFluentApi = true });
+ return string.Join(
+ Environment.NewLine,
+ fileSystem.CreatedFiles
+ .Where(c => Path.GetExtension(c) == ".cs")
+ .Select(c => Encoding.UTF8.GetString(fileSystem.Get(c))));
+ }
+
+ private static string ExtractMethodBody(string code, string methodName)
+ {
+ Match match = Regex.Match(code, @"\s" + Regex.Escape(methodName) + @"\(\s*this\s");
+ Assert.That(match.Success, Is.True, $"Method {methodName} was not generated.");
+ int end = code.IndexOf("return state;", match.Index, StringComparison.Ordinal);
+ Assert.That(end, Is.GreaterThan(match.Index));
+ return code[match.Index..end];
+ }
+
+ private const string FlowVariableTypeNodeSet = """
+
+
+
+ http://test.org/UA/FlowVariableType/
+
+
+
+
+
+ i=11
+ i=45
+ i=40
+ i=35
+ i=47
+ i=37
+
+
+ FlowVariableType
+
+ i=63
+
+
+
+ RatedFlowVariableType
+
+ i=63
+
+
+ 42.5
+
+
+
+ UnratedFlowVariableType
+
+ ns=1;i=2001
+
+
+
+ UnsetRatedFlow
+
+ ns=1;i=2001
+ i=85
+
+
+
+ RatedFlowMeter
+
+ i=58
+ i=85
+ ns=1;i=5002
+ ns=1;i=5003
+
+
+
+ UnsetFlow
+
+ ns=1;i=2001
+
+
+
+ SetFlow
+
+ ns=1;i=2001
+
+
+ 7.25
+
+
+
+ RatedFlowMeterType
+
+ i=58
+ ns=1;i=6000
+
+
+
+ RatedFlow
+
+ ns=1;i=2001
+ i=78
+
+
+ 12.5
+
+
+
+ RatedFlowMeter1
+
+ ns=1;i=1001
+ i=85
+ ns=1;i=5005
+
+
+
+ RatedFlow
+
+ ns=1;i=2001
+
+
+
+ """;
+ }
+}
diff --git a/tests/Opc.Ua.SourceGeneration.Tests/NodesetDependencyValueAbsentTests.cs b/tests/Opc.Ua.SourceGeneration.Tests/NodesetDependencyValueAbsentTests.cs
new file mode 100644
index 0000000000..c6e79215d1
--- /dev/null
+++ b/tests/Opc.Ua.SourceGeneration.Tests/NodesetDependencyValueAbsentTests.cs
@@ -0,0 +1,149 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Text.RegularExpressions;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using NUnit.Framework;
+
+namespace Opc.Ua.SourceGeneration
+{
+ ///
+ /// A Variable that a NodeSet2 declares without a Value has a null Value.
+ /// An instance inherits such a child from its type; when the type's model
+ /// is supplied by a referenced assembly, the child comes from the
+ /// dependency payload, which did not carry that the Value is absent, so
+ /// the inherited child fell back to the default value of its DataType.
+ ///
+ [TestFixture]
+ [Category("SourceGeneration")]
+ [SetCulture("en-us")]
+ [SetUICulture("en-us")]
+ public class NodesetDependencyValueAbsentTests
+ {
+ private const string NullValue = "baseState.WrappedValue = global::Opc.Ua.Variant.Null;";
+
+ [Test]
+ public void InheritedChildWithoutValueIsNullWhenTheTypeModelIsAnAdditionalFile()
+ {
+ string generated = Generate(
+ ModelDependencyScannerTests.CreateStackCompilation("InheritedValueAbsentWithDi"),
+ EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml"),
+ EmbeddedText.From("InheritedValueAbsent.NodeSet2.xml"));
+
+ AssertRevisionCounterIsNull(generated);
+ }
+
+ [Test]
+ public void InheritedChildWithoutValueIsNullWhenTheTypeModelIsAReferencedAssembly()
+ {
+ string generated = Generate(
+ ModelDependencyScannerTests
+ .CreateStackCompilation("InheritedValueAbsent")
+ .AddReferences(s_diProducer.Value.ToMetadataReference()),
+ EmbeddedText.From("InheritedValueAbsent.NodeSet2.xml"));
+
+ AssertRevisionCounterIsNull(generated);
+ }
+
+ private static void AssertRevisionCounterIsNull(string generated)
+ {
+ string body = ExtractMethodBody(generated, "CreateInheritingPump_RevisionCounter");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(body, Does.Contain(NullValue));
+ Assert.That(body, Does.Not.Contain("(int)0"));
+ });
+ }
+
+ private static string Generate(CSharpCompilation compilation, params AdditionalText[] texts)
+ {
+ GeneratorDriver driver = CreateDriver(texts).RunGeneratorsAndUpdateCompilation(
+ compilation,
+ out Compilation output,
+ out ImmutableArray diagnostics);
+ Assert.That(
+ diagnostics.Concat(output.GetDiagnostics()).Where(d => d.Severity == DiagnosticSeverity.Error),
+ Is.Empty,
+ string.Join(Environment.NewLine, diagnostics.Concat(output.GetDiagnostics())));
+ return string.Join(
+ Environment.NewLine,
+ driver.GetRunResult().Results[0].GeneratedSources
+ .Where(s => s.HintName.StartsWith("InheritedValueAbsent.", StringComparison.Ordinal))
+ .Select(s => s.SourceText.ToString()));
+ }
+
+ private static GeneratorDriver CreateDriver(params AdditionalText[] texts)
+ {
+ return CSharpGeneratorDriver.Create(new ModelSourceGenerator())
+ .WithUpdatedParseOptions(new CSharpParseOptions()
+ .WithKind(SourceCodeKind.Regular)
+ .WithLanguageVersion(LanguageVersion.CSharp11))
+ .AddAdditionalTexts([.. texts])
+ .WithUpdatedAnalyzerConfigOptions(new AnalyzerOptionsProvider(
+ new Dictionary
+ {
+ ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true"
+ }));
+ }
+
+ private static CSharpCompilation CreateDiProducer()
+ {
+ CSharpCompilation compilation = ModelDependencyScannerTests
+ .CreateStackCompilation("InheritedValueAbsentDiProducer");
+ CreateDriver(EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml")).RunGeneratorsAndUpdateCompilation(
+ compilation,
+ out Compilation output,
+ out ImmutableArray diagnostics);
+ Assert.That(
+ diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error),
+ Is.Empty,
+ string.Join(Environment.NewLine, diagnostics));
+ return (CSharpCompilation)output;
+ }
+
+ private static string ExtractMethodBody(string code, string methodName)
+ {
+ Match match = Regex.Match(
+ code,
+ @"\s" + Regex.Escape(methodName) + @"\(\s*this\s");
+ Assert.That(match.Success, Is.True, $"Method {methodName} was not generated.");
+ int end = code.IndexOf("return state;", match.Index, StringComparison.Ordinal);
+ Assert.That(end, Is.GreaterThan(match.Index));
+ return code[match.Index..end];
+ }
+
+ private static readonly Lazy s_diProducer = new(CreateDiProducer);
+ }
+}
diff --git a/tests/Opc.Ua.Vision.Tests/VisionBuilderIntegrationTests.cs b/tests/Opc.Ua.Vision.Tests/VisionBuilderIntegrationTests.cs
index 11e74e372e..47efc1da39 100644
--- a/tests/Opc.Ua.Vision.Tests/VisionBuilderIntegrationTests.cs
+++ b/tests/Opc.Ua.Vision.Tests/VisionBuilderIntegrationTests.cs
@@ -673,6 +673,74 @@ namespace Opc.Ua.Vision.Tests
Is.Not.Null);
}
+ [Test]
+ public async Task MandatoryPropertiesTheConfigurationLeavesUnsetHaveAValue()
+ {
+ await using var fixture = new VisionServerFixture();
+ await fixture.StartAsync().ConfigureAwait(false);
+ IVisionBuildContext context = fixture.CreateBuildContext();
+
+ context.Nodes
+ .AddFrame("Frame1", f => f.WithFrameId("frame1"))
+ .AddImageSensor(
+ "Cam1",
+ s => s
+ .WithSensorId("SN-CAM")
+ .AddIntrinsicCalibration("Intr", _ => { })
+ .AddExtrinsicCalibration("Extr", _ => { })
+ .AddStreamEndpoint("Stream", _ => { })
+ .AddClipEndpoint("Clip", _ => { }))
+ .AddPipeline("Pipe1", _ => { });
+
+ var frame = (CoordinateFrameState)FindChild(fixture.Manager.Root.Frames!, "Frame1")!;
+ var camera = (ImageSensorState)FindChild(fixture.Manager.Root.Sensors!, "Cam1")!;
+ var intrinsic = (IntrinsicCalibrationState)FindChild(camera.Calibrations!, "Intr")!;
+ var extrinsic = (ExtrinsicCalibrationState)FindChild(camera.Calibrations!, "Extr")!;
+ var stream = (StreamEndpointState)FindChild(camera.Media!.StreamEndpoints!, "Stream")!;
+ var clip = (ClipEndpointState)FindChild(camera.Media!.ClipEndpoints!, "Clip")!;
+ var pipeline = (InferencePipelineState)FindChild(fixture.Manager.Root.Pipelines!, "Pipe1")!;
+ BaseVariableState[] published =
+ [
+ frame.Role!,
+ camera.RealityKind!,
+ camera.Modality!,
+ intrinsic.PerformedAt!,
+ intrinsic.Valid!,
+ extrinsic.PerformedAt!,
+ extrinsic.Valid!,
+ extrinsic.SourceFrame!,
+ extrinsic.TargetFrame!,
+ stream.State!,
+ stream.Authentication!,
+ stream.SecureTransport!,
+ clip.State!,
+ clip.Authentication!,
+ clip.SecureTransport!,
+ pipeline.State!,
+ pipeline.Sensor!,
+ pipeline.Deployment!
+ ];
+
+ Assert.Multiple(() =>
+ {
+ foreach (BaseVariableState variable in published)
+ {
+ Assert.That(variable.WrappedValue.IsNull, Is.False, variable.BrowseName.Name);
+ }
+ Assert.That(frame.Role!.Value, Is.EqualTo(VisionFrameRoleEnum.Other));
+ Assert.That(camera.RealityKind!.Value, Is.EqualTo(VisionRealityKindEnum.Physical));
+ Assert.That(camera.Modality!.Value, Is.EqualTo(VisionSensorModalityEnum.Area2D));
+ Assert.That(intrinsic.PerformedAt!.Value, Is.EqualTo(DateTimeUtc.MinValue));
+ Assert.That(intrinsic.Valid!.Value, Is.True);
+ Assert.That(extrinsic.Valid!.Value, Is.True);
+ Assert.That(extrinsic.SourceFrame!.Value, Is.EqualTo(NodeId.Null));
+ Assert.That(stream.State!.Value, Is.EqualTo(VisionEndpointStateEnum.Inactive));
+ Assert.That(clip.Authentication!.Value, Is.EqualTo(VisionEndpointAuthenticationEnum.None));
+ Assert.That(pipeline.State!.Value, Is.EqualTo(VisionEndpointStateEnum.Inactive));
+ Assert.That(pipeline.Deployment!.Value, Is.EqualTo(NodeId.Null));
+ });
+ }
+
[Test]
public async Task BuildContextExposesVisionAndInstanceNamespaceIndexes()
{
diff --git a/tools/Opc.Ua.SourceGeneration.Core/Dependency/ModelDependencyV1.cs b/tools/Opc.Ua.SourceGeneration.Core/Dependency/ModelDependencyV1.cs
index fb0b62aecc..295ad983c3 100644
--- a/tools/Opc.Ua.SourceGeneration.Core/Dependency/ModelDependencyV1.cs
+++ b/tools/Opc.Ua.SourceGeneration.Core/Dependency/ModelDependencyV1.cs
@@ -285,6 +285,14 @@ namespace Opc.Ua.SourceGeneration.Dependency
///
public string? DefaultValueXml { get; set; }
+ ///
+ /// Whether the variable was imported from a NodeSet2 that declares no
+ /// Value for it, so that its Value is null rather than the default
+ /// value of its DataType (variables only). Carried as a variable flag
+ /// that older readers ignore.
+ ///
+ public bool NodeSetValueIsNull { get; set; }
+
///
/// The namespace table the namespace indexes in
/// refer to in the producing model
@@ -494,6 +502,9 @@ namespace Opc.Ua.SourceGeneration.Dependency
private const byte kRawUserAccessLevel = 0x04;
private const byte kMinimumSamplingIntervalSpecified = 0x08;
private const byte kHistorizingSpecified = 0x10;
+ // A reader that predates this flag ignores it and keeps implying the
+ // DataType default, which is what it did before.
+ private const byte kNodeSetValueIsNull = 0x20;
///
/// Namespace URI that qualifies the standard reference types.
@@ -886,6 +897,10 @@ namespace Opc.Ua.SourceGeneration.Dependency
{
variableFlags |= kHistorizingSpecified;
}
+ if (child.NodeSetValueIsNull)
+ {
+ variableFlags |= kNodeSetValueIsNull;
+ }
writer.Write(variableFlags);
writer.Write(child.AccessLevel);
writer.Write(child.RawAccessLevel.GetValueOrDefault());
@@ -922,6 +937,7 @@ namespace Opc.Ua.SourceGeneration.Dependency
child.MinimumSamplingIntervalSpecified = (flags & kMinimumSamplingIntervalSpecified) != 0;
child.Historizing = reader.ReadBoolean();
child.HistorizingSpecified = (flags & kHistorizingSpecified) != 0;
+ child.NodeSetValueIsNull = (flags & kNodeSetValueIsNull) != 0;
child.DefaultValueXml = ReadNullableString(reader);
if (child.DefaultValueXml != null)
{
diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/ModelDependencyGenerator.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/ModelDependencyGenerator.cs
index b4cb3500e7..9d5755ef2b 100644
--- a/tools/Opc.Ua.SourceGeneration.Core/Generators/ModelDependencyGenerator.cs
+++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/ModelDependencyGenerator.cs
@@ -382,6 +382,8 @@ namespace Opc.Ua.SourceGeneration
effectiveVariable.HistorizingSpecified;
entryChild.DefaultValueXml =
effectiveVariable.DefaultValue?.OuterXml;
+ entryChild.NodeSetValueIsNull =
+ effectiveVariable.NodeSetValueIsNull;
entryChild.DefaultValueNamespaceUris =
GetDefaultValueNamespaceUris(effectiveVariable);
}
diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateGenerator.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateGenerator.cs
index bb77c9521f..e68278d3f4 100644
--- a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateGenerator.cs
+++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateGenerator.cs
@@ -3081,22 +3081,29 @@ namespace Opc.Ua.SourceGeneration
Tokens.StateClassFactory,
"new global::Opc.Ua.BaseDataVariableTypeState");
- context.Template.AddReplacement(Tokens.ValueCode, CoreUtils.Format(
- "baseState.WrappedValue = {0};",
- node.DataTypeNode!.GetValueAsCode(
- node.ValueRank,
- node.DefaultValue,
- node.DecodedValue,
- true,
- m_context.ModelDesign.TargetNamespace.Value,
- m_context.ModelDesign.Namespaces,
- m_messageContext,
- () => AddXmlInitializerForComplexValue(
- node,
- node.DataTypeNode!,
- node.DefaultValue)!,
- node.DecodedValueNamespaceUris,
- kNamespaceTableContextVariable)));
+ if (IsNodeSetValueNull(node.NodeSetValueIsNull, node.DefaultValue, node.DecodedValue))
+ {
+ context.Template.AddReplacement(Tokens.ValueCode, kNullValueCode);
+ }
+ else
+ {
+ context.Template.AddReplacement(Tokens.ValueCode, CoreUtils.Format(
+ "baseState.WrappedValue = {0};",
+ node.DataTypeNode!.GetValueAsCode(
+ node.ValueRank,
+ node.DefaultValue,
+ node.DecodedValue,
+ true,
+ m_context.ModelDesign.TargetNamespace.Value,
+ m_context.ModelDesign.Namespaces,
+ m_messageContext,
+ () => AddXmlInitializerForComplexValue(
+ node,
+ node.DataTypeNode!,
+ node.DefaultValue)!,
+ node.DecodedValueNamespaceUris,
+ kNamespaceTableContextVariable)));
+ }
string dataTypeId =
GetNodeIdConstantForDataType(node, m_context.ModelDesign.Namespaces);
context.Template.AddReplacement(
@@ -3225,6 +3232,10 @@ namespace Opc.Ua.SourceGeneration
[arguments.ToList()],
WriteTemplate_ArgumentCollection);
}
+ else if (IsNodeSetValueNull(node.NodeSetValueIsNull, node.DefaultValue, node.DecodedValue))
+ {
+ context.Template.AddReplacement(Tokens.ValueCode, kNullValueCode);
+ }
else
{
context.Template.AddReplacement(Tokens.ValueCode, CoreUtils.Format(
@@ -3246,6 +3257,20 @@ namespace Opc.Ua.SourceGeneration
}
}
+ ///
+ /// A Variable or VariableType imported from a NodeSet2 without a Value
+ /// has a null Value. Only a ModelDesign implies the DataType default.
+ ///
+ private static bool IsNodeSetValueNull(
+ bool nodeSetValueIsNull,
+ System.Xml.XmlElement? defaultValue,
+ object? decodedValue)
+ {
+ return nodeSetValueIsNull && defaultValue == null && decodedValue == null;
+ }
+
+ private const string kNullValueCode = "baseState.WrappedValue = global::Opc.Ua.Variant.Null;";
+
private static Parameter[]? GetMethodArgumentParameters(NodeToGenerate node)
{
if (node.Parent?.Design is not MethodDesign method)
diff --git a/tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesign.cs b/tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesign.cs
index 8b30ec0f2f..5579b39e72 100644
--- a/tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesign.cs
+++ b/tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesign.cs
@@ -982,6 +982,14 @@ namespace Opc.Ua.Schema.Model
[XmlIgnore]
public DataTypeDesign? DataTypeNode { get; set; }
+ ///
+ /// Whether the node was imported from a NodeSet2 that declares no
+ /// Value for it. Its Value is then null (OPC 10000-6 F.5) rather than
+ /// the default value of its DataType, which a ModelDesign implies.
+ ///
+ [XmlIgnore]
+ public bool NodeSetValueIsNull { get; set; }
+
///
/// The verbatim OPC UA AccessLevel bitmask imported from a
/// NodeSet2 model. The ModelDesign
@@ -1025,6 +1033,7 @@ namespace Opc.Ua.Schema.Model
AccessLevelSpecified == other.AccessLevelSpecified &&
RawAccessLevel == other.RawAccessLevel &&
RawUserAccessLevel == other.RawUserAccessLevel &&
+ NodeSetValueIsNull == other.NodeSetValueIsNull &&
InstanceAccessLevel == other.InstanceAccessLevel &&
InstanceAccessLevelSpecified == other.InstanceAccessLevelSpecified &&
MinimumSamplingInterval == other.MinimumSamplingInterval &&
@@ -1046,6 +1055,7 @@ namespace Opc.Ua.Schema.Model
hash.Add(AccessLevelSpecified);
hash.Add(RawAccessLevel);
hash.Add(RawUserAccessLevel);
+ hash.Add(NodeSetValueIsNull);
hash.Add(InstanceAccessLevel);
hash.Add(InstanceAccessLevelSpecified);
hash.Add(MinimumSamplingInterval);
@@ -1094,6 +1104,14 @@ namespace Opc.Ua.Schema.Model
[XmlIgnore]
public DataTypeDesign? DataTypeNode { get; set; }
+ ///
+ /// Whether the node was imported from a NodeSet2 that declares no
+ /// Value for it. Its Value is then null (OPC 10000-6 F.5) rather than
+ /// the default value of its DataType, which a ModelDesign implies.
+ ///
+ [XmlIgnore]
+ public bool NodeSetValueIsNull { get; set; }
+
///
public override bool Equals(object? obj)
{
@@ -1121,6 +1139,7 @@ namespace Opc.Ua.Schema.Model
MinimumSamplingIntervalSpecified == other.MinimumSamplingIntervalSpecified &&
Historizing == other.Historizing &&
HistorizingSpecified == other.HistorizingSpecified &&
+ NodeSetValueIsNull == other.NodeSetValueIsNull &&
ExposesItsChildren == other.ExposesItsChildren;
}
@@ -1140,6 +1159,7 @@ namespace Opc.Ua.Schema.Model
hash.Add(MinimumSamplingIntervalSpecified);
hash.Add(Historizing);
hash.Add(HistorizingSpecified);
+ hash.Add(NodeSetValueIsNull);
hash.Add(ExposesItsChildren);
return hash.ToHashCode();
}
diff --git a/tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesignValidator.cs b/tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesignValidator.cs
index 8f1ea27036..5c1304be43 100644
--- a/tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesignValidator.cs
+++ b/tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesignValidator.cs
@@ -5294,6 +5294,19 @@ namespace Opc.Ua.Schema.Model
VariableTypeDesign mergedType,
VariableTypeDesign variableType)
{
+ // A NodeSet2 VariableType without a Value has a null Value: it does
+ // not take over the Value of its supertype, and neither do the
+ // instances created from it.
+ mergedType.NodeSetValueIsNull = variableType.NodeSetValueIsNull;
+ if (HasAbsentNodeSetValue(
+ variableType.NodeSetValueIsNull,
+ variableType.DefaultValue,
+ variableType.DecodedValue))
+ {
+ mergedType.DecodedValue = null;
+ mergedType.DecodedValueNamespaceUris = null;
+ mergedType.DefaultValue = null;
+ }
if (variableType.DecodedValue != null)
{
mergedType.DecodedValue = variableType.DecodedValue;
@@ -5340,6 +5353,19 @@ namespace Opc.Ua.Schema.Model
}
}
+ ///
+ /// Whether a node imported from a NodeSet2 declares no Value, so that
+ /// the Value it would otherwise take over from its supertype, its
+ /// TypeDefinition or the instance declaration it overrides is cleared.
+ ///
+ private static bool HasAbsentNodeSetValue(
+ bool nodeSetValueIsNull,
+ System.Xml.XmlElement? defaultValue,
+ object? decodedValue)
+ {
+ return nodeSetValueIsNull && defaultValue == null && decodedValue == null;
+ }
+
private static void MergeTypes(
ObjectTypeDesign mergedType,
ObjectTypeDesign objectType)
@@ -5496,6 +5522,7 @@ namespace Opc.Ua.Schema.Model
MinimumSamplingIntervalSpecified = type.MinimumSamplingIntervalSpecified,
Historizing = type.Historizing,
HistorizingSpecified = type.HistorizingSpecified,
+ NodeSetValueIsNull = type.NodeSetValueIsNull,
Category = type.Category,
Purpose = type.Purpose,
ReleaseStatus = type.ReleaseStatus,
@@ -5700,6 +5727,20 @@ namespace Opc.Ua.Schema.Model
VariableDesign mergedVariable,
VariableDesign variable)
{
+ // A NodeSet2 node without a Value has a null Value, whatever its
+ // DataType or TypeDefinition; a ModelDesign declaration implies the
+ // DataType default.
+ mergedVariable.NodeSetValueIsNull = variable.NodeSetValueIsNull;
+ if (HasAbsentNodeSetValue(
+ variable.NodeSetValueIsNull,
+ variable.DefaultValue,
+ variable.DecodedValue))
+ {
+ mergedVariable.DecodedValue = null;
+ mergedVariable.DecodedValueNamespaceUris = null;
+ mergedVariable.DefaultValue = null;
+ }
+
if (variable.TypeDefinition != null &&
variable.TypeDefinition != s_baseDataVariableTypeQn)
{
@@ -7313,6 +7354,7 @@ namespace Opc.Ua.Schema.Model
c.MinimumSamplingIntervalSpecified;
variable.Historizing = c.Historizing;
variable.HistorizingSpecified = c.HistorizingSpecified;
+ variable.NodeSetValueIsNull = c.NodeSetValueIsNull;
if (c.DefaultValueXml != null)
{
variable.DefaultValue = ParseDependencyDefaultValue(
diff --git a/tools/Opc.Ua.SourceGeneration.Core/Schema/NodeSetToModelDesign.cs b/tools/Opc.Ua.SourceGeneration.Core/Schema/NodeSetToModelDesign.cs
index ca41566031..b2ea31c4b0 100644
--- a/tools/Opc.Ua.SourceGeneration.Core/Schema/NodeSetToModelDesign.cs
+++ b/tools/Opc.Ua.SourceGeneration.Core/Schema/NodeSetToModelDesign.cs
@@ -772,6 +772,7 @@ namespace Opc.Ua.Schema.Model
UpdateTypeDesign(input, output);
output.DefaultValue = input.Value;
+ output.NodeSetValueIsNull = input.Value == null;
output.ValueRankSpecified = true;
output.ValueRank = ImportValueRank(input.ValueRank);
output.ValueRankSpecified = true;
@@ -1118,6 +1119,7 @@ namespace Opc.Ua.Schema.Model
UpdateInstanceDesign(input, output);
output.DefaultValue = input.Value;
+ output.NodeSetValueIsNull = input.Value == null;
output.ValueRank = ImportValueRank(input.ValueRank);
output.ValueRankSpecified = true;
output.ArrayDimensions = ImportArrayDimensions(input.ValueRank, input.ArrayDimensions);
diff --git a/tools/Opc.Ua.SourceGeneration/readme.md b/tools/Opc.Ua.SourceGeneration/readme.md
index 1b5f099d3b..77619a5984 100644
--- a/tools/Opc.Ua.SourceGeneration/readme.md
+++ b/tools/Opc.Ua.SourceGeneration/readme.md
@@ -53,6 +53,13 @@ An `.xml` `AdditionalFiles` item whose root element is neither a `ModelDesign`
(`http://opcfoundation.org/UA/2011/03/UANodeSet.xsd`) is not a model input and is skipped,
so XML files meant for other analyzers or tools can share the `AdditionalFiles` item group.
+A Variable or VariableType that a `NodeSet2` declares without a `` element has a null
+Value (OPC 10000-6 F.5): the generated node, its `CreateInstanceOf*` factory and every instance
+that inherits it start with `Variant.Null`, also when the model is consumed from a referenced
+assembly. A `ModelDesign` declaration without a `DefaultValue` keeps the default value of its
+DataType. A server that wants such a variable to hold a value before it has one to publish sets
+it when it creates the node, for example an empty structure for a structured DataType.
+
Per-file behaviour is controlled with `AdditionalFiles` metadata:
| Metadata | Description |