From fc0687a2dc26943a9cd4cb5065fa38a24cbd950c Mon Sep 17 00:00:00 2001 From: Wolfgang Colsman <52833340+wolfgangcolsman@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:16:26 -0400 Subject: [PATCH] Source generator: a NodeSet2 Variable without a Value has a null Value (#4798) # Description A Variable or VariableType in a NodeSet2 that has no Value element has a null Value (OPC 10000-6 F.5); the runtime NodeSet2 importer loads it that way. The source generator initialized such a node with the default value of its DataType instead ((int)0, (double)0, ...), so a server built from the generated code reported values the model never declared. The NodeSet2 importer now records that the node declares no Value (NodeSetValueIsNull), the flag follows the node into the merged instance hierarchy, and the node state generator emits Variant.Null for it when no value was merged in from elsewhere. ModelDesign inputs, where a missing DefaultValue implies the DataType default, are unchanged. The tests generate a DI-style NodeSet2 together with the DI NodeSet2 and check the generated values of a type's InstanceDeclaration, of an instance's children with and without a Value, and of a VariableType. ## Related Issues None. ## Checklist - [x] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [x] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [x] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [x] I have addressed **all** PR feedback received. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Marc Schier --- docs/Extrusion.md | 6 + docs/Vision.md | 9 + .../ExtrusionCommonProviders.cs | 7 + .../ExtrusionDevice.Common.cs | 28 +- .../Intent/IntentControllerHost.cs | 6 + .../Builders/VisionNodeBuilder.cs | 53 +++ .../ExtrusionFixture.cs | 12 + .../ExtrusionModelCoverageTests.cs | 50 +++ .../ModelDependencyGeneratorTests.cs | 50 +++ .../InheritedValueAbsent.NodeSet2.xml | 37 ++ .../Schema/NodeSetValueAbsentTests.cs | 334 ++++++++++++++++++ .../NodesetDependencyValueAbsentTests.cs | 149 ++++++++ .../VisionBuilderIntegrationTests.cs | 68 ++++ .../Dependency/ModelDependencyV1.cs | 16 + .../Generators/ModelDependencyGenerator.cs | 2 + .../Generators/NodeStateGenerator.cs | 57 ++- .../Schema/ModelDesign.cs | 20 ++ .../Schema/ModelDesignValidator.cs | 42 +++ .../Schema/NodeSetToModelDesign.cs | 2 + tools/Opc.Ua.SourceGeneration/readme.md | 7 + 20 files changed, 933 insertions(+), 22 deletions(-) create mode 100644 tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/InheritedValueAbsent.NodeSet2.xml create mode 100644 tests/Opc.Ua.SourceGeneration.Core.Tests/Schema/NodeSetValueAbsentTests.cs create mode 100644 tests/Opc.Ua.SourceGeneration.Tests/NodesetDependencyValueAbsentTests.cs 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 |