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 |