mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 16:52:07 +08:00
15d0ce7bd5
# 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.
353 lines
19 KiB
C#
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"];
|
|
}
|
|
}
|