Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
273053c
Select the session's preferred translation of localized texts in Read…
romanett Oct 5, 2026
5530352
ComplexTypes: encode only IsOptional subtyped fields as ExtensionObje…
romanett Oct 5, 2026
dadb8ce
Avoid constant array arguments in the locale selection tests
romanett Oct 5, 2026
f0b229f
Name the XML ExtensionObject body of dynamic structures after the dat…
romanett Oct 5, 2026
d6db995
Server: enforce role permissions on type nodes beyond visibility and …
romanett Oct 5, 2026
007112a
NodeManagement and Call: Part 3 compliance fixes
romanett Oct 5, 2026
97a6196
Merge branch 'romanett/part3-fix-complextypes'
romanett Oct 5, 2026
c874694
Merge branch 'romanett/part3-fix-nodemanagement-call'
romanett Oct 5, 2026
0a2677a
ComplexTypes parity test: compare XML of both builders exactly
romanett Oct 5, 2026
f43da00
Merge branch 'romanett/part3-fix-locales'
romanett Oct 5, 2026
a309e17
Merge branch 'romanett/part3-fix-permissions'
romanett Oct 5, 2026
baf651f
Enforce Part 3/Part 4 Write service Value semantics on variables
romanett Oct 5, 2026
6ee35ec
Merge branch 'romanett/part3-fix-write'
romanett Oct 5, 2026
e3a0473
Complete the DataTypeDefinition of DataTypes loaded from NodeSets (Pa…
romanett Oct 5, 2026
f65ad4c
PubSub subscriber: drop timestamps for TargetVariables that do not ac…
romanett Oct 5, 2026
2e76837
Merge branch 'romanett/part3-fix-nodeset-datatypes'
romanett Oct 5, 2026
596419a
Call: verify and invoke the Object's Method when methodId is the type…
romanett Oct 5, 2026
aae074b
Fix CA1307 in UANodeSetDataTypeDefinitionTests
romanett Oct 5, 2026
e1dc088
MigrationGuide: document the Part 3 compliance behaviour changes
romanett Oct 5, 2026
21495f5
ComplexTypes client test: write the value back without a SourceTimestamp
romanett Oct 5, 2026
9d0a22b
Address review comments on the Part 3 compliance changes
romanett Oct 5, 2026
8e09ee5
Reference server: advertise StatusWrite and TimestampWrite on writabl…
romanett Oct 5, 2026
21b21ea
Reference server: keep AccessLevelEx consistent with the added write …
romanett Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions docs/MigrationGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ covers cross-cutting changes.
- [Transport resource limits](#transport-resource-limits)
- [Migrating channel subclasses that override HandleIncomingMessage](#migrating-channel-subclasses-that-override-handleincomingmessage)
- [Migrating custom IUserDatabase implementations](#migrating-custom-iuserdatabase-implementations)
- [Write service Value semantics follow OPC 10000-3 and OPC 10000-4](#write-service-value-semantics-follow-opc-10000-3-and-opc-10000-4)
- [Address space permissions, Call, NodeManagement and locales follow OPC 10000-3](#address-space-permissions-call-nodemanagement-and-locales-follow-opc-10000-3)
- [Migrating from 1.05.377 to 1.05.378](#migrating-from-105377-to-105378)
- [Asynchronous as default](#asynchronous-as-default)
- [Observability](#observability)
Expand Down Expand Up @@ -927,6 +929,85 @@ to event where-clauses and every other `ContentFilter`:
Clients whose where-clauses relied on the old matching of missing fields should
test them explicitly with `IsNull`, for example `Or(IsNull(field), Equals(field, 0))`.

## Write service Value semantics follow OPC 10000-3 and OPC 10000-4

A Value written through the Write service is now checked the same way on every path of
`BaseVariableState` ([OPC 10000-4 §5.11.4](https://reference.opcfoundation.org/Core/Part4/v105/docs/5.11.4)):

- **StatusWrite / TimestampWrite.** Without the `StatusWrite` bit of the AccessLevel only the
StatusCode Good may be written, and without the `TimestampWrite` bit only a null
SourceTimestamp ([OPC 10000-3 §8.57](https://reference.opcfoundation.org/Core/Part3/v105/docs/8.57)).
Other combinations return `Bad_WriteNotSupported`. The bits must also be present in the
effective UserAccessLevel of the caller (including `OnReadUserAccessLevel`); otherwise the
write returns `Bad_UserAccessDenied`. "Good" is compared by the code bits only, so Good
with info bits (for example SemanticsChanged or LimitBits) needs no StatusWrite, while a
Good SubCode such as `Good_Clamped` does. Server code that assigns `Value`,
`StatusCode` or `Timestamp` directly is not affected. Set
`AccessLevels.StatusWrite | AccessLevels.TimestampWrite` on both the AccessLevel and the
UserAccessLevel of Variables whose clients legitimately write status codes or source
timestamps.
- **Type check before `OnWriteValue`.** The DataType and ValueRank are verified before the
synchronous `OnWriteValue` handler (fluent `OnWrite`) runs, so a handler no longer
receives a value of the wrong type; the write returns `Bad_TypeMismatch`.
- **IndexRange with `OnWriteValue`.** After the handler accepts an IndexRange write, the slice
is merged into the cached value instead of replacing it.
- **Enumerations.** Writing an Int32 that is not a defined value of an Enumeration DataType
registered with the encodeable factory returns `Bad_OutOfRange` (also for every element of
an array or matrix).
- **OptionSet.** A written OptionSet structure must have Value and ValidBits of the same size
as the stored value and may only select valid bits, otherwise `Bad_OutOfRange`. The valid
bits are the ValidBits of the stored value (an all-zero mask means no bit is valid), or the
bits of the OptionSet DataType definition when the stored value has no ValidBits. The selected
bits are merged into the stored value
([OPC 10000-3 §8.40](https://reference.opcfoundation.org/Core/Part3/v105/docs/8.40)).
The same applies to each element of an OptionSet array or matrix, including elements
written through an IndexRange.

Server code that changes a Property with semantic meaning (for example `EURange` or
`EngineeringUnits`) directly calls `ReportPropertyValueChanged(context, property, previousValue)`
or `ReportSemanticChange(context, property)` on its `AsyncCustomNodeManager` or
`CustomNodeManager2`, so that a SemanticChangeEvent is raised and the next value notification
carries the SemanticsChanged bit ([OPC 10000-3 §5.6.2](https://reference.opcfoundation.org/Core/Part3/v105/docs/5.6.2)).

The PubSub subscriber that writes TargetVariables of an external server retries a write that
fails with `Bad_WriteNotSupported` without the received timestamps and remembers the target
([OPC 10000-14 §6.2.11.1](https://reference.opcfoundation.org/Core/Part14/v105/docs/6.2.11.1)).

## Address space permissions, Call, NodeManagement and locales follow OPC 10000-3

- **Role permissions on type nodes.** ObjectTypes, VariableTypes and their children stay
browsable and readable for every user, but all other permission bits are now checked on
them ([OPC 10000-3 §8.55](https://reference.opcfoundation.org/Core/Part3/v105/docs/8.55)).
In particular `ReceiveEvents` is verified on the EventType: the standard NodeSet grants it on
`AuditEventType` and its subtypes only to `SecurityAdmin`, so Anonymous and
AuthenticatedUser sessions no longer receive audit events. Give the users that monitor
audit events the SecurityAdmin role (or change the RolePermissions of the audit event types).
`MasterNodeManager.ValidateAccessRestrictions` has an overload that takes the requested
permission; the existing two-argument overload behaves as a Browse/Read request.
- **Call.** The `Call` permission is required on the Object passed as `objectId` as well as on
the Method, and the Object's AccessRestrictions apply. When the `methodId` is the Method
declaration of the ObjectType, the Method of the Object with the same BrowseName is
permission-checked and invoked ([OPC 10000-4 §5.12.2.2](https://reference.opcfoundation.org/Core/Part4/v105/docs/5.12.2.2));
bind method handlers on the instance Methods. Trailing input arguments described by
`HasOptionalInputArgumentDescription` may be omitted by the client and reach the handler as
`Variant.Null`; override `MethodState.GetOptionalInputArgumentCount` to customize this.
- **NodeManagement.** AddNodes rejects abstract TypeDefinitions (including `BaseVariableType`,
`BaseEventType` and Interfaces) with `Bad_TypeDefinitionInvalid` and creates the Mandatory
InstanceDeclarations of the type below the new node. AddReferences, DeleteReferences and
DeleteNodes bump the `NodeVersion` and raise `GeneralModelChangeEvent`s.
- **Locales.** Read (DisplayName, Description, InverseName, LocalizedText values), Browse and
data change notifications return the translation that best matches the session's LocaleIds,
or all translations for `mul`
([OPC 10000-4 §5.4](https://reference.opcfoundation.org/Core/Part4/v105/docs/5.4)). The
selection uses `ResourceManager.TranslateValue`, which calls the overridable
`ResourceManager.Translate`. Locale ids are matched case-insensitively.
- **NodeSet import.** `UANodeSet.Import` completes the `StructureDefinition` of structure
subtypes with the fields of their base types (base fields first,
[OPC 10000-3 §8.48](https://reference.opcfoundation.org/Core/Part3/v105/docs/8.48)), creates a
definition for structures without fields, and sets the `DefaultEncodingId` of concrete
structures. `StructureDefinition.FirstExplicitFieldIndex` marks the first field of the
subtype; NodeSet export writes only those fields.

## Migrating from 1.05.377 to 1.05.378

### Asynchronous as default
Expand Down
46 changes: 46 additions & 0 deletions samples/Quickstarts.Servers/SampleNodeManager/SampleNodeManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,48 @@ protected void AddTypesToTypeTree(NodeId typeId)
return null;
}

/// <summary>
/// Resolves a Method declaration found in the ObjectType hierarchy to the
/// Method of the Object with the same BrowseName.
/// Per OPC UA spec Part 4 section 5.12.2.2 the RolePermissions are always
/// verified with the Method that is the target of a HasComponent from the
/// Object, independent of the methodId of the call.
/// </summary>
/// <param name="context">The system context.</param>
/// <param name="source">The Object the method is called on.</param>
/// <param name="declaration">The Method declaration of the ObjectType.</param>
/// <returns>The Method of the Object, or the declaration if the Object has none.</returns>
private MethodState FindObjectMethodForDeclaration(
ISystemContext context,
NodeState source,
MethodState declaration)
{
if (source.FindChildWithQualifiedName(context, declaration.BrowseName) is MethodState child)
{
return child;
}

// check for loose coupling via a HasComponent reference of the Object.
var references = new List<IReference>();
source.GetReferences(context, references, ReferenceTypeIds.HasComponent, false);
foreach (IReference reference in references)
{
if (reference.TargetId.IsNull || reference.TargetId.IsAbsolute)
{
continue;
}

MethodState? method = FindPredefinedNode<MethodState>(
ExpandedNodeId.ToNodeId(reference.TargetId, Server.NamespaceUris));
if (method != null && method.BrowseName == declaration.BrowseName)
{
return method;
}
}

return declaration;
}

/// <summary>
/// Frees any resources allocated for the address space.
/// </summary>
Expand Down Expand Up @@ -1870,6 +1912,10 @@ public virtual void Call(
if (method == null && source is BaseInstanceState instanceState)
{
method = FindMethodInTypeHierarchy(systemContext, instanceState.TypeDefinitionId, methodToCall.MethodId);
if (method != null)
{
method = FindObjectMethodForDeclaration(systemContext, source, method);
}
}

if (method == null)
Expand Down
27 changes: 27 additions & 0 deletions src/Opc.Ua.Client.ComplexTypes/TypeBuilder/AssemblyModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@
* ======================================================================*/

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Reflection.Emit;
using System.Threading;

namespace Opc.Ua.Client.ComplexTypes
{
Expand Down Expand Up @@ -70,8 +72,33 @@ public Type[] GetTypes()
return m_assemblyBuilder.GetTypes();
}

/// <summary>
/// Register a type that is created without Reflection.Emit,
/// e.g. a Structure-backed OptionSet sub-type.
/// </summary>
internal void AddRuntimeType(IType type)
{
lock (m_runtimeTypesLock)
{
m_runtimeTypes.Add(type);
}
}

/// <summary>
/// Get the types created without Reflection.Emit.
/// </summary>
internal IReadOnlyList<IType> GetRuntimeTypes()
{
lock (m_runtimeTypesLock)
{
return [.. m_runtimeTypes];
}
}

private readonly AssemblyBuilder m_assemblyBuilder;
private readonly ModuleBuilder m_moduleBuilder;
private readonly List<IType> m_runtimeTypes = [];
private readonly Lock m_runtimeTypesLock = new();
private const string kOpcTypesModuleName = "Opc.Ua.ComplexTypes.Module";
}
}
38 changes: 35 additions & 3 deletions src/Opc.Ua.Client.ComplexTypes/TypeBuilder/AttributeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,26 @@ public static void StructureFieldAttribute(
StructureField structureField,
bool allowSubTypes,
bool isEnum)
{
typeBuilder.StructureFieldAttribute(structureField, allowSubTypes, isEnum, default);
}

/// <summary>
/// Build the StructureField attribute for a complex type.
/// </summary>
/// <param name="typeBuilder">The property builder.</param>
/// <param name="structureField">The field.</param>
/// <param name="allowSubTypes">Whether the field allows subtypes.</param>
/// <param name="isEnum">Whether the field is an enumeration.</param>
/// <param name="dataTypeId">The namespace-uri qualified DataType id of an
/// encodeable field whose runtime type does not identify its DataType (for
/// example an OptionSet subtype), or null.</param>
public static void StructureFieldAttribute(
this PropertyBuilder typeBuilder,
StructureField structureField,
bool allowSubTypes,
bool isEnum,
ExpandedNodeId dataTypeId)
{
Type attributeType = typeof(StructureFieldAttribute);
// Reflection on a locally-defined attribute: ctor and properties are known.
Expand All @@ -174,9 +194,12 @@ public static void StructureFieldAttribute(
(int)BuiltInType.Enumeration :
(int)GetBuiltInType(structureField.DataType);

// IEncodeable types are handled by type property as BuiltInType.Null
// But if we allow subtypes for the field we encode as extension object
if (builtInType == 0 && allowSubTypes)
// IEncodeable types are handled by type property as BuiltInType.Null.
// OPC 10000-6 5.1.7: in a StructureWithSubtypedValues or
// UnionWithSubtypedValues only a field with IsOptional=TRUE allows
// subtypes and is serialized as an ExtensionObject; with
// IsOptional=FALSE the field value is encoded directly.
if (builtInType == 0 && allowSubTypes && structureField.IsOptional)
{
builtInType = (int)BuiltInType.ExtensionObject;
}
Expand All @@ -186,6 +209,15 @@ public static void StructureFieldAttribute(
pi.Add(attributeType.GetProperty("BuiltInType")!);
pv.Add(builtInType);
}
else if (!dataTypeId.IsNull)
{
// an encodeable field is written with the declared DataType id,
// also when its runtime type does not carry it (e.g. an OptionSet).
// The id is stored with its namespace uri, so it does not depend on
// the namespace table of the session that built the type.
pi.Add(attributeType.GetProperty("DataTypeId")!);
pv.Add(dataTypeId.ToString());
}

var builder = new CustomAttributeBuilder(
ctorInfo,
Expand Down
31 changes: 24 additions & 7 deletions src/Opc.Ua.Client.ComplexTypes/TypeBuilder/ComplexTypeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
using System.Collections.Generic;
using System.Reflection;
using System.Reflection.Emit;
using System.Xml;

namespace Opc.Ua.Client.ComplexTypes
{
Expand All @@ -53,6 +54,7 @@ public ComplexTypeBuilder(
TargetNamespace = targetNamespace;
TargetNamespaceIndex = targetNamespaceIndex;
m_moduleName = FindModuleName(moduleName, targetNamespace);
m_moduleFactory = moduleFactory;
m_moduleBuilder = moduleFactory.GetModuleBuilder();
}

Expand Down Expand Up @@ -175,21 +177,35 @@ public IComplexTypeFieldBuilder AddStructuredType(
}

/// <summary>
/// OptionSet sub-types are not supported by the Reflection.Emit
/// complex type builder. Use the default (source-generated)
/// complex type builder instead for OptionSet support.
/// Create a Structure-backed OptionSet sub-type. The wire format is
/// the inherited <c>Value</c>/<c>ValidBits</c> ByteStrings of the
/// abstract OptionSet DataType, so no type is emitted: the runtime
/// OptionSet type carries the TypeId, the encoding ids and the bits.
/// </summary>
/// <exception cref="NotSupportedException">Always thrown.</exception>
/// <exception cref="ArgumentNullException"><paramref name="enumDefinition"/> is <c>null</c>.</exception>
public IEncodeableType AddOptionSetType(
QualifiedName typeName,
ExpandedNodeId typeId,
ExpandedNodeId binaryEncodingId,
ExpandedNodeId xmlEncodingId,
EnumDefinition enumDefinition)
{
throw new NotSupportedException(
"OptionSet DataTypes are not supported by the Reflection.Emit " +
"complex type builder. Use the default complex type builder.");
if (enumDefinition == null)
{
throw new ArgumentNullException(nameof(enumDefinition));
}
if (typeName.IsNull || string.IsNullOrEmpty(typeName.Name))
{
throw new ArgumentNullException(nameof(typeName));
}
var type = new Encoders.OptionSet(
new XmlQualifiedName(typeName.Name, TargetNamespace),
typeId,
binaryEncodingId,
xmlEncodingId,
enumDefinition);
m_moduleFactory.AddRuntimeType(type);
return type;
}

/// <summary>
Expand Down Expand Up @@ -230,6 +246,7 @@ private string GetFullQualifiedTypeName(QualifiedName browseName)
return result + browseName.Name;
}

private readonly AssemblyModule m_moduleFactory;
private readonly ModuleBuilder m_moduleBuilder;
private readonly string m_moduleName;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ public IComplexTypeBuilder Create(
public IReadOnlyList<IType> GetTypes()
{
return m_moduleFactory.GetTypes()
.Select(t => new GeneratedType(t))
.Select(t => (IType)new GeneratedType(t))
.Concat(m_moduleFactory.GetRuntimeTypes())
.ToList();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ public void AddField(StructureField field, IType? fieldType, int order, bool all
// skip unresolved fields before reaching this point.
Type typeOfField = fieldType!.Type;
bool isEnum = fieldType is IEnumeratedType || typeOfField.IsEnum;
if (allowSubTypes &&
field.IsOptional &&
!isEnum &&
fieldType is IEncodeableType &&
typeOfField != typeof(ExtensionObject))
{
// OPC 10000-6 5.1.7: the field allows the DataType and any of its
// subtypes, which are unrelated generated types, so the property
// holds any encodeable and is serialized as an ExtensionObject.
typeOfField = typeof(IEncodeable);
}
if (field.ValueRank == ValueRanks.OneDimension)
{
typeOfField = typeOfField.MakeArrayType();
Expand Down Expand Up @@ -137,7 +148,17 @@ public void AddField(StructureField field, IType? fieldType, int order, bool all
propertyBuilder.SetGetMethod(getBuilder);
propertyBuilder.SetSetMethod(setBuilder);
propertyBuilder.DataMemberAttribute(fieldName, false, order);
propertyBuilder.StructureFieldAttribute(field, allowSubTypes, isEnum);
// a runtime encodeable type (e.g. an OptionSet subtype) does not identify
// its DataType by its .NET type; record its namespace-uri qualified TypeId.
ExpandedNodeId dataTypeId = default;
if (fieldType is IEncodeable encodeable &&
!encodeable.TypeId.IsNull &&
(encodeable.TypeId.NamespaceIndex == 0 ||
!string.IsNullOrEmpty(encodeable.TypeId.NamespaceUri)))
{
dataTypeId = encodeable.TypeId;
}
propertyBuilder.StructureFieldAttribute(field, allowSubTypes, isEnum, dataTypeId);
}

/// <inheritdoc/>
Expand Down
Loading
Loading