mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 14:43:08 +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.
260 lines
12 KiB
C#
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;
|
|
}
|
|
}
|