Files
Marc Schier 15d0ce7bd5 Implement OPC UA Part 10 Programs (#4789)
# Description

Implement OPC UA Part 10 Programs across `Opc.Ua.Core`/`Core.Types`,
`Opc.Ua.Server`, and `Opc.Ua.Client`, reusing the existing
state-machine, source-generation, node-management, auditing, and
streaming-subscription infrastructure.

- Add asynchronous provider-backed execution, standard controls,
internal/autonomous transitions, generation checks, and nested-state
activation/resumption.
- Add checked creation/adoption, vendor Create adapters, deletion,
recycling and instance limits, auto-delete, DI, and direct-construction
paths.
- Maintain ProgramDiagnostic2 and retained final results; publish
captured intermediate results and concrete transition/audit events with
access controls.
- Add generated SDK models/proxies, typed client discovery and reads,
event streams, and subscribe-before-call readiness handling.
- Add unit, connected client/server, generated Annex A-style model, and
NativeAOT coverage. Document usage and migration in `docs/Programs.md`
and related guides.

### Compatibility and design decisions

The linked Part 10 document is v1.05.00; namespace-zero definitions
remain aligned with 1.05.07. Documentation distinguishes normative
requirements from conflicting specification text and SDK conventions.

- Correct state numbers to Halted=11, Ready=12, Running=13,
Suspended=14. Generated subtypes retain inherited namespace-zero
state/transition identifiers.
- Managed Reset is Halted-to-Ready only. Preserve the existing low-level
Reset-from-Suspended behavior for compatibility.
- Preserve UInt32 limit types; use `UInt32.MaxValue` as a documented SDK
unlimited convention. RecycleCount overflow reports BadOutOfRange
without stopping unlimited execution.
- In-memory default with injectable providers. Durable recovery,
distributed execution/failover, and a production Annex A download engine
are excluded.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] 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.
- [ ] 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

Tests and focused coverage evidence are above; the combined
tests/coverage checkbox remains open because a whole-repository coverage
increase has not been established. CLA status, CI/CodeQL results,
tracking-issue approval, and PR feedback are not asserted.
2026-10-15 09:19:25 +02:00

260 lines
12 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 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.Linq;
using System.Reflection;
using NUnit.Framework;
namespace Opc.Ua.SourceGeneration.Generator.Tests
{
/// <summary>
/// Generates and compiles the event records of a companion model.
/// </summary>
[TestFixture]
[Category("Generator")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
[Parallelizable]
public class EventRecordModelTests
{
private const string ModelUri = "http://test.org/UA/Ev/";
[OneTimeSetUp]
public void OneTimeSetUp()
{
Dictionary<string, string> generated = DataTypeModelTests.Generate(
Model,
omitEventRecords: false);
m_records = generated
.Single(f => f.Key.EndsWith("EventRecords.g.cs", StringComparison.Ordinal))
.Value;
m_assembly = DataTypeModelTests.Compile(generated);
}
/// <summary>
/// D-15: a select clause is matched including the namespace index
/// of the browse names, so the browse names a companion model
/// declares must be in the model's namespace of the session. The
/// standard fields stay in namespace 0.
/// </summary>
[Test]
public void VendorFieldBrowsePathsUseTheModelNamespace()
{
Type decoder = m_assembly.GetType(
"Test.Ev.MachineEventTypeRecord+Decoder",
throwOnError: true)!;
MethodInfo getStandardFields = decoder!.GetMethod(
"GetStandardFields",
BindingFlags.Public | BindingFlags.Static)!;
var namespaceUris = new NamespaceTable();
namespaceUris.Append("urn:other");
namespaceUris.Append(ModelUri);
var paths = (QualifiedName[][])getStandardFields!.Invoke(null, [namespaceUris])!;
QualifiedName machineId = paths!.Single(p => p[0].Name == "MachineId")[0];
Assert.That(machineId.NamespaceIndex, Is.EqualTo(2));
QualifiedName eventId = paths.Single(p => p[0].Name == "EventId")[0];
Assert.That(eventId.NamespaceIndex, Is.Zero);
// The positional layout without a session keeps its positions.
var standardFields = (QualifiedName[][])decoder
.GetField("StandardFields", BindingFlags.Public | BindingFlags.Static)!
.GetValue(null)!;
Assert.That(
standardFields!.Select(p => p[0].Name),
Is.EqualTo(paths.Select(p => p[0].Name)));
Assert.That(
m_records,
Does.Contain("MachineEventTypeRecord.Decoder.GetStandardFields(namespaceUris)"));
}
/// <summary>
/// S2 (OPC 10000-4 7.7.4.5, 7.22.3): when the session does not know
/// the model's namespace the browse name used to fall back to
/// namespace 0, where it could match a standard field of the same
/// name. It now gets an index no server table holds, so the server
/// returns null for the field. The companion StandardFields table,
/// all in namespace 0, is marked obsolete.
/// </summary>
[Test]
public void VendorFieldOfAnUnknownNamespaceCannotMatchNamespaceZero()
{
Type decoder = m_assembly.GetType(
"Test.Ev.MachineEventTypeRecord+Decoder",
throwOnError: true)!;
MethodInfo getStandardFields = decoder!.GetMethod(
"GetStandardFields",
BindingFlags.Public | BindingFlags.Static)!;
var namespaceUris = new NamespaceTable();
namespaceUris.Append("urn:other");
var paths = (QualifiedName[][])getStandardFields!.Invoke(null, [namespaceUris])!;
QualifiedName machineId = paths!.Single(p => p[0].Name == "MachineId")[0];
Assert.That(machineId.NamespaceIndex, Is.EqualTo(ushort.MaxValue));
QualifiedName eventId = paths.Single(p => p[0].Name == "EventId")[0];
Assert.That(eventId.NamespaceIndex, Is.Zero);
FieldInfo standardFields = decoder.GetField(
"StandardFields",
BindingFlags.Public | BindingFlags.Static)!;
Assert.That(
standardFields!.GetCustomAttribute<ObsoleteAttribute>(),
Is.Not.Null,
"the all-namespace-0 companion table must point callers to GetStandardFields");
}
/// <summary>
/// D-16: a property whose data type is a simple subtype of a
/// built-in type (no class is generated for it) is represented by
/// the built-in type; a model data type named like a standard one
/// is not mistaken for it.
/// </summary>
[Test]
public void VendorDataTypesMapToGeneratedOrBuiltInTypes()
{
Type record = m_assembly.GetType(
"Test.Ev.MachineEventTypeRecord",
throwOnError: true)!;
Assert.That(record!.GetProperty("Load")!.PropertyType, Is.EqualTo(typeof(double?)));
Assert.That(record.GetProperty("Span")!.PropertyType.FullName, Is.EqualTo("Test.Ev.Duration"));
Assert.That(record.GetProperty("Elapsed")!.PropertyType, Is.EqualTo(typeof(double?)));
}
/// <summary>
/// Program transition records select captured state identifiers as well as display values.
/// </summary>
[Test]
[Category("Programs")]
public void ProgramEventRecordIncludesCapturedStateIds()
{
string model = Model.Replace(
"BaseType=\"ua:BaseEventType\"",
"BaseType=\"ua:ProgramTransitionEventType\"",
StringComparison.Ordinal);
string records = DataTypeModelTests.Generate(model, omitEventRecords: false)
.Single(f => f.Key.EndsWith("EventRecords.g.cs", StringComparison.Ordinal))
.Value;
string compactRecords = string.Concat(records.Where(c => !char.IsWhiteSpace(c)));
foreach (string name in new[] { "FromState", "ToState", "Transition" })
{
Assert.That(compactRecords, Does.Contain(
$"global::Opc.Ua.BrowseNames.{name})," +
"global::Opc.Ua.QualifiedName.From(global::Opc.Ua.BrowseNames.Id)"));
Assert.That(compactRecords,
Does.Contain($"{name}Id=global::Opc.Ua.EventRecordFieldReaders.GetVariant("));
}
Assert.That(compactRecords,
Does.Contain("IntermediateResult=global::Opc.Ua.EventRecordFieldReaders.GetVariant("));
}
/// <summary>
/// Concrete optional controls on a vendor Program retain the declared argument signature.
/// </summary>
[Test]
[Category("Programs")]
public void ProgramControlProxyPreservesSubtypeArguments()
{
const string model =
"""
<opc:ModelDesign
xmlns:opc="http://opcfoundation.org/UA/ModelDesign.xsd"
xmlns:ua="http://opcfoundation.org/UA/"
xmlns="http://test.org/UA/Program/"
TargetNamespace="http://test.org/UA/Program/">
<opc:Namespaces>
<opc:Namespace Name="Program" Prefix="Test.Program">http://test.org/UA/Program/</opc:Namespace>
<opc:Namespace Name="OpcUa" Prefix="Opc.Ua">http://opcfoundation.org/UA/</opc:Namespace>
</opc:Namespaces>
<opc:ObjectType SymbolicName="ArgumentProgramType" BaseType="ua:ProgramStateMachineType">
<opc:Children>
<opc:Method SymbolicName="Start" ModellingRule="Optional">
<opc:InputArguments>
<opc:Argument Name="BatchId" DataType="ua:String" />
</opc:InputArguments>
<opc:OutputArguments>
<opc:Argument Name="JobId" DataType="ua:NodeId" />
</opc:OutputArguments>
</opc:Method>
</opc:Children>
</opc:ObjectType>
</opc:ModelDesign>
""";
string proxies = DataTypeModelTests.Generate(model)
.Single(f => f.Key.EndsWith("TypeProxies.g.cs", StringComparison.Ordinal))
.Value;
Assert.Multiple(() =>
{
Assert.That(proxies, Does.Contain("ValueTask<global::Opc.Ua.NodeId> StartAsync("));
Assert.That(proxies, Does.Contain("string batchId,"));
Assert.That(proxies, Does.Contain("global::Opc.Ua.Variant.From(batchId)"));
Assert.That(proxies, Does.Contain("global::Opc.Ua.ProgramStateMachineTypeClient"));
});
}
private const string Model =
"""
<?xml version="1.0" encoding="utf-8" ?>
<opc:ModelDesign
xmlns:opc="http://opcfoundation.org/UA/ModelDesign.xsd"
xmlns:ua="http://opcfoundation.org/UA/"
xmlns="http://test.org/UA/Ev/"
TargetNamespace="http://test.org/UA/Ev/">
<opc:Namespaces>
<opc:Namespace Name="Ev" Prefix="Test.Ev">http://test.org/UA/Ev/</opc:Namespace>
<opc:Namespace Name="OpcUa" Prefix="Opc.Ua" XmlNamespace="http://opcfoundation.org/UA/2008/02/Types.xsd">http://opcfoundation.org/UA/</opc:Namespace>
</opc:Namespaces>
<opc:DataType SymbolicName="Percent" BaseType="ua:Double" />
<opc:DataType SymbolicName="Duration" BaseType="ua:Structure">
<opc:Fields>
<opc:Field Name="Ms" DataType="ua:Int64" />
</opc:Fields>
</opc:DataType>
<opc:ObjectType SymbolicName="MachineEventType" BaseType="ua:BaseEventType">
<opc:Children>
<opc:Property SymbolicName="MachineId" DataType="ua:String" />
<opc:Property SymbolicName="Load" DataType="Percent" />
<opc:Property SymbolicName="Span" DataType="Duration" />
<opc:Property SymbolicName="Elapsed" DataType="ua:Duration" />
</opc:Children>
</opc:ObjectType>
</opc:ModelDesign>
""";
private string m_records;
private Assembly m_assembly;
}
}