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

353 lines
19 KiB
C#

/* ========================================================================
* 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.Linq;
using NUnit.Framework;
using Opc.Ua.Programs;
using Opc.Ua.Tests;
using ProgramModelIds = Opc.Ua.Programs.ObjectTypeIds;
using ProgramNamespaces = Opc.Ua.Programs.Namespaces;
namespace Opc.Ua.Core.Tests.Programs
{
/// <summary>
/// Verifies the standard Program identifiers and generated SDK information model.
/// </summary>
[TestFixture]
[Category("Programs")]
[Parallelizable(ParallelScope.All)]
public sealed class ProgramModelTests
{
/// <summary>
/// Runtime state numbers agree with the namespace-zero model.
/// </summary>
[TestCase(ProgramState.Halted, Objects.ProgramStateMachineType_Halted, 11u)]
[TestCase(ProgramState.Ready, Objects.ProgramStateMachineType_Ready, 12u)]
[TestCase(ProgramState.Running, Objects.ProgramStateMachineType_Running, 13u)]
[TestCase(ProgramState.Suspended, Objects.ProgramStateMachineType_Suspended, 14u)]
public void StateMappingsAndRuntimeNumbersMatchStandard(ProgramState state, uint id, uint number)
{
SystemContext context = CreateContext();
var machine = new ProgramStateMachineState(null);
machine.Create(context, new NodeId("Invocation", 2), new QualifiedName("Invocation", 2), default, false);
machine.SetState(context, id);
Assert.Multiple(() =>
{
Assert.That(ProgramStateMachineModel.GetStateId(state), Is.EqualTo(new NodeId(id)));
Assert.That(ProgramStateMachineModel.TryGetState(new NodeId(id), out ProgramState actual), Is.True);
Assert.That(actual, Is.EqualTo(state));
Assert.That((uint)state, Is.EqualTo(number));
Assert.That(machine.CurrentState!.Number!.Value, Is.EqualTo(number));
Assert.That(machine.CurrentState.Id!.Value, Is.EqualTo(new NodeId(id)));
});
}
/// <summary>
/// A generated subtype's element namespace must not relocate inherited standard Program states.
/// </summary>
[TestCase(ProgramState.Halted)]
[TestCase(ProgramState.Ready)]
[TestCase(ProgramState.Running)]
[TestCase(ProgramState.Suspended)]
public void GeneratedProgramSubtypesRetainNamespaceZeroBaseStateIds(ProgramState state)
{
SystemContext context = CreateContext();
var machine = new ManagedProgramState(null);
machine.Create(context, new NodeId("Derived", 2), new QualifiedName("Derived", 2), default, false);
NodeId stateId = ProgramStateMachineModel.GetStateId(state);
Assert.That(stateId.TryGetValue(out uint id), Is.True);
machine.SetState(context, id);
Assert.Multiple(() =>
{
Assert.That(machine.CurrentState!.Id!.Value, Is.EqualTo(stateId));
Assert.That(machine.GetStateId(stateId), Is.EqualTo(id));
Assert.That(machine.GetTransitionNodeId(Objects.ProgramStateMachineType_ReadyToRunning),
Is.EqualTo(ObjectIds.ProgramStateMachineType_ReadyToRunning));
});
}
/// <summary>
/// The nine transitions retain their standard identifiers and transition numbers.
/// </summary>
[TestCase(ProgramState.Halted, ProgramState.Ready, Objects.ProgramStateMachineType_HaltedToReady, 1u)]
[TestCase(ProgramState.Ready, ProgramState.Running, Objects.ProgramStateMachineType_ReadyToRunning, 2u)]
[TestCase(ProgramState.Running, ProgramState.Halted, Objects.ProgramStateMachineType_RunningToHalted, 3u)]
[TestCase(ProgramState.Running, ProgramState.Ready, Objects.ProgramStateMachineType_RunningToReady, 4u)]
[TestCase(ProgramState.Running, ProgramState.Suspended,
Objects.ProgramStateMachineType_RunningToSuspended, 5u)]
[TestCase(ProgramState.Suspended, ProgramState.Running,
Objects.ProgramStateMachineType_SuspendedToRunning, 6u)]
[TestCase(ProgramState.Suspended, ProgramState.Halted, Objects.ProgramStateMachineType_SuspendedToHalted, 7u)]
[TestCase(ProgramState.Suspended, ProgramState.Ready, Objects.ProgramStateMachineType_SuspendedToReady, 8u)]
[TestCase(ProgramState.Ready, ProgramState.Halted, Objects.ProgramStateMachineType_ReadyToHalted, 9u)]
public void TransitionMappingsMatchRuntime(ProgramState from, ProgramState to, uint id, uint number)
{
SystemContext context = CreateContext();
var machine = new ProgramStateMachineState(null);
machine.Create(context, new NodeId("Invocation", 2), new QualifiedName("Invocation", 2), default, false);
Assert.That(ProgramStateMachineModel.GetStateId(from).TryGetValue(out uint fromId), Is.True);
machine.SetState(context, fromId);
ServiceResult result = machine.DoTransition(context, id, 0, default, []);
Assert.Multiple(() =>
{
Assert.That(ServiceResult.IsGood(result), Is.True);
Assert.That(ProgramStateMachineModel.GetTransitionId(from, to), Is.EqualTo(new NodeId(id)));
Assert.That(machine.CurrentState!.Id!.Value, Is.EqualTo(ProgramStateMachineModel.GetStateId(to)));
Assert.That(machine.CurrentState.Number!.Value, Is.EqualTo((uint)to));
Assert.That(machine.LastTransition!.Id!.Value, Is.EqualTo(new NodeId(id)));
Assert.That(machine.LastTransition.Number!.Value, Is.EqualTo(number));
});
}
/// <summary>
/// Control method mappings preserve standard declaration identifiers.
/// </summary>
[TestCase(ProgramControlMethod.Start, Methods.ProgramStateMachineType_Start)]
[TestCase(ProgramControlMethod.Suspend, Methods.ProgramStateMachineType_Suspend)]
[TestCase(ProgramControlMethod.Resume, Methods.ProgramStateMachineType_Resume)]
[TestCase(ProgramControlMethod.Halt, Methods.ProgramStateMachineType_Halt)]
[TestCase(ProgramControlMethod.Reset, Methods.ProgramStateMachineType_Reset)]
public void ControlMethodsRoundTrip(ProgramControlMethod method, uint id)
{
Assert.That(ProgramStateMachineModel.GetMethodId(method), Is.EqualTo(new NodeId(id)));
Assert.That(ProgramStateMachineModel.TryGetControlMethod(new NodeId(id), out ProgramControlMethod actual),
Is.True);
Assert.That(actual, Is.EqualTo(method));
}
/// <summary>
/// Bad enum values and unsupported transitions are not silently mapped to a valid identifier.
/// </summary>
[Test]
public void InvalidMappingsAreRejected()
{
Assert.Multiple(() =>
{
Assert.That(() => ProgramStateMachineModel.GetStateId((ProgramState)0),
Throws.TypeOf<ArgumentOutOfRangeException>());
Assert.That(() => ProgramStateMachineModel.GetMethodId((ProgramControlMethod)99),
Throws.TypeOf<ArgumentOutOfRangeException>());
Assert.That(() => ProgramStateMachineModel.GetTransitionId((ProgramState)0, ProgramState.Ready),
Throws.TypeOf<ArgumentOutOfRangeException>());
Assert.That(() => ProgramStateMachineModel.GetTransitionId(ProgramState.Ready, (ProgramState)0),
Throws.TypeOf<ArgumentOutOfRangeException>());
Assert.That(() => ProgramStateMachineModel.GetTransitionId(ProgramState.Ready, ProgramState.Ready),
Throws.ArgumentException);
Assert.That(() => ProgramStateMachineModel.GetTransitionId(ProgramState.Halted, ProgramState.Running),
Throws.ArgumentException);
});
foreach (NodeId id in new NodeId[]
{
NodeId.Null,
new(Objects.ProgramStateMachineType_Ready, 2),
new(Methods.ProgramStateMachineType_Start, 2),
new("2400", 0),
new(999999u)
})
{
Assert.That(ProgramStateMachineModel.TryGetState(id, out _), Is.False);
Assert.That(ProgramStateMachineModel.TryGetControlMethod(id, out _), Is.False);
}
}
/// <summary>
/// The legacy low-level Reset from Suspended remains supported.
/// </summary>
[Test]
public void LegacyResetFromSuspendedRemainsSupported()
{
SystemContext context = CreateContext();
var machine = new ProgramStateMachineState(null);
machine.Create(context, new NodeId("Invocation", 2), new QualifiedName("Invocation", 2), default, false);
machine.SetState(context, Objects.ProgramStateMachineType_Suspended);
var reset = new MethodState(machine) { BrowseName = QualifiedName.From(BrowseNames.Reset) };
ServiceResult result = machine.DoCause(context, reset, Methods.ProgramStateMachineType_Reset, default, []);
Assert.That(ServiceResult.IsGood(result), Is.True);
Assert.That(machine.CurrentState!.Id!.Value, Is.EqualTo(ObjectIds.ProgramStateMachineType_Ready));
Assert.That(machine.CurrentState.Number!.Value, Is.EqualTo(12u));
}
/// <summary>
/// The SDK declares concrete optional methods and a type-only Create with its generated signature.
/// </summary>
[Test]
public void ManagedProgramTypeDeclaresOptionalControlsAndTypeOnlyCreate()
{
SystemContext context = CreateContext();
NodeStateCollection nodes = new NodeStateCollection().AddOpcUaPrograms(context);
NodeId typeId = ExpandedNodeId.ToNodeId(ProgramModelIds.ManagedProgramType, context.NamespaceUris);
var type = (BaseObjectTypeState)nodes.Single(n => n.NodeId == typeId);
var children = new List<BaseInstanceState>();
type.GetChildren(context, children);
MethodState[] controls = [.. children.OfType<MethodState>().Where(m => m.BrowseName.Name != "Create")];
Assert.That(controls.Select(m => m.BrowseName.Name),
Is.EquivalentTo(s_controlNames));
foreach (MethodState control in controls)
{
Assert.That(control.BrowseName.NamespaceIndex, Is.Zero);
Assert.That(control.ModellingRuleId, Is.EqualTo(ObjectIds.ModellingRule_Optional));
Assert.That(control.InputArguments?.Value.Count ?? 0, Is.Zero);
Assert.That(control.OutputArguments?.Value.Count ?? 0, Is.Zero);
}
MethodState create = children.OfType<MethodState>().Single(m => m.BrowseName.Name == "Create");
Assert.Multiple(() =>
{
Assert.That(type.SuperTypeId, Is.EqualTo(ObjectTypeIds.ProgramStateMachineType));
Assert.That(type.IsAbstract, Is.False);
Assert.That(create.ModellingRuleId.IsNull, Is.True);
Assert.That(create.InputArguments!.Value.ToArray()!.Select(a => a.Name),
Is.EqualTo(s_createInputNames));
Assert.That(create.InputArguments.Value.ToArray()!.Select(a => a.DataType),
Is.EqualTo(new[] { DataTypeIds.NodeId, DataTypeIds.QualifiedName }));
Assert.That(create.OutputArguments!.Value.Count, Is.EqualTo(1));
Assert.That(create.OutputArguments.Value[0].Name, Is.EqualTo("InvocationId"));
Assert.That(create.OutputArguments.Value[0].DataType, Is.EqualTo(DataTypeIds.NodeId));
});
}
/// <summary>
/// Instances inherit diagnostics/results support without a Create method or type-level policy properties.
/// </summary>
[Test]
public void ManagedProgramInstanceDoesNotInstantiateTypeOnlyMembers()
{
SystemContext context = CreateContext();
ManagedProgramState machine = context.CreateInstanceOfManagedProgramType();
machine.CreateAsPredefinedNode(context);
var children = new List<BaseInstanceState>();
machine.GetChildren(context, children);
Assert.Multiple(() =>
{
Assert.That(children.Any(c => c.BrowseName.Name == "Create"), Is.False);
Assert.That(children.Any(c => c.BrowseName.Name == BrowseNames.Creatable), Is.False);
Assert.That(children.Any(c => c.BrowseName.Name == BrowseNames.InstanceCount), Is.False);
Assert.That(machine.CurrentState!.Number!.Value, Is.EqualTo(12u));
Assert.That(machine.Start, Is.Null);
Assert.That(machine.ProgramDiagnostic, Is.Null);
Assert.That(machine.FinalResultData, Is.Null);
});
MethodState start = machine.CreateOrReplaceStart(context, null, false);
machine.WireCauseMethods(context);
Assert.That(machine.Start, Is.SameAs(start));
Assert.That(start.OnCallMethod, Is.Not.Null);
Assert.That(start.BrowseName.NamespaceIndex, Is.Zero);
Assert.That(machine.CreateOrReplaceProgramDiagnostic(context, null, false),
Is.TypeOf<ProgramDiagnostic2State>());
Assert.That(machine.CreateOrReplaceFinalResultData(context, null, false), Is.TypeOf<BaseObjectState>());
}
/// <summary>
/// SDK event declarations are concrete and retain the required standard ancestry.
/// </summary>
[Test]
public void SdkEventsAreConcreteStandardSubtypes()
{
SystemContext context = CreateContext();
BaseObjectTypeState transition = context.CreateManagedProgramTransitionEventType();
BaseObjectTypeState audit = context.CreateManagedProgramAuditEventType();
Assert.Multiple(() =>
{
Assert.That(transition.IsAbstract, Is.False);
Assert.That(transition.SuperTypeId, Is.EqualTo(ObjectTypeIds.ProgramTransitionEventType));
Assert.That(audit.IsAbstract, Is.False);
Assert.That(audit.SuperTypeId, Is.EqualTo(ObjectTypeIds.AuditProgramTransitionEventType));
Assert.That(transition.NodeId.NamespaceIndex, Is.EqualTo(2));
Assert.That(audit.NodeId.NamespaceIndex, Is.EqualTo(2));
});
}
/// <summary>
/// Generated event fields capture IDs and intermediate results without rereading mutable nodes.
/// </summary>
[Test]
public void GeneratedTransitionRecordsDecodeCapturedStateIdsAndIntermediateResults()
{
SystemContext context = CreateContext();
var registry = new EventRecordDecoderRegistry().RegisterOpcUaProgramsDecoders(context.NamespaceUris);
QualifiedName[][] paths = registry.StandardFields;
var fields = new Variant[paths.Length];
NodeId eventTypeId = ExpandedNodeId.ToNodeId(
ProgramModelIds.ManagedProgramTransitionEventType, context.NamespaceUris);
fields[FindField(paths, BrowseNames.EventType)] = Variant.From(eventTypeId);
fields[FindField(paths, BrowseNames.SourceNode)] = Variant.From(new NodeId("Invocation", 2));
fields[FindField(paths, BrowseNames.FromState, BrowseNames.Id)] =
Variant.From(ObjectIds.ProgramStateMachineType_Running);
fields[FindField(paths, BrowseNames.ToState, BrowseNames.Id)] =
Variant.From(ObjectIds.ProgramStateMachineType_Suspended);
fields[FindField(paths, BrowseNames.Transition, BrowseNames.Id)] =
Variant.From(ObjectIds.ProgramStateMachineType_RunningToSuspended);
fields[FindField(paths, BrowseNames.IntermediateResult)] = Variant.From(42);
var record = registry.Decode(fields) as ManagedProgramTransitionEventTypeRecord;
Assert.That(record, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(record!.FromStateId, Is.EqualTo(Variant.From(ObjectIds.ProgramStateMachineType_Running)));
Assert.That(record.ToStateId, Is.EqualTo(Variant.From(ObjectIds.ProgramStateMachineType_Suspended)));
Assert.That(record.TransitionId,
Is.EqualTo(Variant.From(ObjectIds.ProgramStateMachineType_RunningToSuspended)));
Assert.That(record.IntermediateResult, Is.EqualTo(Variant.From(42)));
Assert.That(record.EventType, Is.EqualTo(eventTypeId));
});
}
private static int FindField(QualifiedName[][] paths, string name, string? child = null)
{
int index = Array.FindIndex(paths,
path => path.Length == (child == null ? 1 : 2) &&
path[0] == QualifiedName.From(name) &&
(child == null || path[1] == QualifiedName.From(child)));
Assert.That(index, Is.GreaterThanOrEqualTo(0), $"Missing event select path {name}/{child}");
return index;
}
private static SystemContext CreateContext()
{
var namespaces = new NamespaceTable();
namespaces.Append("urn:namespace-remapping-test");
namespaces.Append(ProgramNamespaces.OpcUaPrograms);
return new SystemContext(NUnitTelemetryContext.Create()) { NamespaceUris = namespaces };
}
private static readonly string[] s_controlNames = ["Start", "Suspend", "Resume", "Halt", "Reset"];
private static readonly string[] s_createInputNames = ["ParentId", "BrowseName"];
}
}