Files
Marc Schier 5b9bcedc62 Add OPC UA ISA-95 Common Model and Job Control support (#4089)
# Description

Adds first-class support for the OPC UA ISA-95 companion specifications:

- OPC-10030 Common Model.
- OPC-10031-4 Job Control V1.
- OPC-10031-4 Job Control V2.

The change introduces `Opc.Ua.ISA95`, `Opc.Ua.ISA95.Client`, and
`Opc.Ua.ISA95.Server`, together with a NativeAOT-compatible minimal
server sample and comprehensive tests.

## Highlights

- Compiles the three canonical NodeSets and identifier mapping CSV files
through the source generator.
- Adds NodeSet identifier mapping validation, typed method
arguments/results, companion event records, and generated method
argument nodes.
- Provides typed Common Model builders with normative relationships and
an injectable `GeoSpatialLocationType` provider.
- Implements lossless V1/V2 Job Control providers over a shared
version-neutral store, including state paths, status events, comments,
response queries, catalog changes, and cohesive DI registration.
- Adds subtype-aware typed clients, standalone encodeable registration,
`IIsa95ClientFactory`, and V2 status-event streaming.
- Adds the `MinimalIsa95Server` sample, package documentation, and an
ISA-95 guide with conformance and HA guidance.
- Tracks typed RSL/GPOS integration as follow-up issue #4095.

## Validation

- `Opc.Ua.ISA95.Tests`: 137/137 passed on `net10.0` and `net48`.
- Source-generator suites: 81/81 model, 90/90 stack, 3718 passed / 8
skipped core.
- `EventRecordFieldReaders` feedback tests: 6/6 on `net10.0` and
`net48`.
- `EventRecordFieldReaders` changed-method patch coverage: 100% line
coverage (36/36) and 100% branch coverage (22/22).
- `Opc.Ua.ISA95`, `.Client`, and `.Server` build across all configured
target frameworks without new diagnostics.
- Cobertura line coverage: 88.08% model, 90.04% client, 91.19% server.
- NativeAOT sample publishes and passes a live typed V2
Store/status-event validation.
- The ISA-95 NativeAOT smoke test passes in the source-generated native
test executable.
- OPC UA KB NodeSet validation reports zero errors for Common, V1, and
V2.

The broader NativeAOT executable still reports unrelated existing GDS
and complex-type test failures; the ISA-95-specific native test passes.

## Node state generator fixes

Generating method arguments inline for imported nodesets regressed every
model that already emits `InputArguments` / `OutputArguments` child node
factories:

- The inline block ran before the child factories.
`MethodState.CreateOrReplaceInputArguments` keeps the property that was
assigned first, so the child carrying the well-known NodeId was dropped
and a NodeId-less placeholder remained in the address space. This broke
browsing (reference descriptions without a target) and made method
arguments unreachable at their well-known NodeIds.
- The `MethodDeclarationId` fallback keyed on an autogenerated display
name redirected every declared method of the standard address space to
its own NodeId, which made method calls fail.

The inline block is now emitted after the child factories and reduced to
a value assignment when a child factory exists. The value is only
re-emitted when an argument references a namespace other than OPC UA,
because the value of an imported property is the verbatim XML of the
source nodeset whose namespace indices do not survive the import. The
`MethodDeclarationId` fallback is restricted to declarations without a
resolvable NodeId. The generated OPC UA address space is byte-identical
to `master` again.

## Related Issues

- Fixes #4088
- Follow-up: #4095
2026-07-28 12:01:57 +02:00

429 lines
16 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.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.Linq;
using Microsoft.Extensions.Logging;
using Opc.Ua.Export;
using Opc.Ua.Schema.Model;
using Opc.Ua.Types;
namespace Opc.Ua.SourceGeneration
{
/// <summary>
/// An entry in the collection
/// </summary>
public sealed class NodesetFile
{
/// <summary>
/// Nodeset information
/// </summary>
public NodesetFileOptions Info { get; init; }
/// <summary>
/// File name
/// </summary>
public string FileName { get; init; }
/// <summary>
/// The nodeset parsed
/// </summary>
public UANodeSet NodeSet { get; init; }
/// <summary>
/// Previous versions
/// </summary>
internal List<NodesetFile> PreviousVersions { get; set; }
}
/// <summary>
/// Nodeset information
/// </summary>
public sealed record class NodesetFileOptions
{
/// <summary>
/// Model uri
/// </summary>
public string ModelUri { get; init; }
/// <summary>
/// Name
/// </summary>
public string Name { get; init; }
/// <summary>
/// Prefix to use
/// </summary>
public string Prefix { get; init; }
/// <summary>
/// Should be ignored
/// </summary>
public bool Ignore { get; init; }
/// <summary>
/// Version
/// </summary>
public string Version { get; init; }
/// <summary>
/// Optional identifier CSV explicitly associated with this NodeSet.
/// </summary>
public string IdentifierFile { get; init; }
}
/// <summary>
/// Nodeset compiler
/// </summary>
public sealed class NodesetFileCollection
{
/// <summary>
/// The files in the collection
/// </summary>
public Dictionary<string, string> Files => m_nodesets
.ToDictionary(x => x.Key, x => x.Value.FileName);
/// <summary>
/// The models in the collection
/// </summary>
public IEnumerable<string> ModelUris => m_nodesets.Values
.Where(x => !x.Info.Ignore)
.Select(x => x.Info.ModelUri)
.Where(x => !string.IsNullOrEmpty(x));
/// <summary>
/// The design file entries for every nodeset in the collection,
/// encoded as <c>"{file},{prefix},{name}"</c> (the same encoding
/// used by <see cref="GetDesignFileListForModel"/>). These are
/// suitable for use as <c>Dependencies</c> of a ModelDesign
/// generation pass so that a ModelDesign referencing a type
/// defined in a NodeSet2 input can resolve it. The built-in OpcUa
/// namespace and ignored nodesets are excluded.
/// </summary>
public IEnumerable<string> DesignFileEntries => m_nodesets.Values
.Where(x => !x.Info.Ignore && x.Info.ModelUri != Namespaces.OpcUa)
.Select(x => $"{x.FileName},{x.Info.Prefix},{x.Info.Name}");
/// <summary>
/// Identifier sidecar validation errors discovered while loading NodeSets.
/// </summary>
public IReadOnlyList<NodesetIdentifierValidationError> IdentifierValidationErrors
=> m_identifierValidationErrors;
/// <summary>
/// Create collection
/// </summary>
public NodesetFileCollection(
ImmutableArray<(string, NodesetFileOptions)> nodeset2Files,
IReadOnlyList<string> csvFiles,
IFileSystem fileSystem,
ITelemetryContext telemetry)
{
m_logger = telemetry.CreateLogger<NodesetFileCollection>();
foreach ((string file, NodesetFileOptions options) in nodeset2Files)
{
try
{
if (!NodeSetToModelDesign.IsNodeSet(fileSystem, file))
{
continue;
}
using Stream istrm = fileSystem.OpenRead(file);
SystemContext systemContext = new(telemetry)
{
NamespaceUris = new NamespaceTable(),
ServerUris = new StringTable()
};
var nodeset = UANodeSet.Read(istrm);
#if !SKIP_NODE_STATE_LOADING
var collection = new NodeStateCollection();
try
{
nodeset.Import(systemContext, collection);
}
catch (Exception e)
{
m_logger.LogError(e, "NodeSet could not be loaded ({File})", file);
return;
}
#endif
if (nodeset.Models == null ||
nodeset.Models.Length == 0 ||
string.IsNullOrEmpty(nodeset.Models[0].ModelUri))
{
m_logger.LogError("NodeSet is missing model definition ({File}).", file);
continue;
}
ModelTableEntry model = nodeset.Models[0];
if (!Uri.IsWellFormedUriString(model.ModelUri, UriKind.Absolute))
{
m_logger.LogError(
"NodeSet ModelURI is not valid ({ModelUri}).", model.ModelUri);
continue;
}
string name = GetNameFromUri(model.ModelUri); // Get a sane name and prefix
var info = new NodesetFile
{
FileName = file,
NodeSet = nodeset,
Info = new NodesetFileOptions // Set reasonable defaults if not provided
{
ModelUri = !string.IsNullOrEmpty(options.ModelUri) ?
options.ModelUri : model.ModelUri,
Version = !string.IsNullOrEmpty(options.Version) ?
options.Version :
model.PublicationDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Name = !string.IsNullOrEmpty(options.Name) ?
options.Name : name,
Prefix = !string.IsNullOrEmpty(options.Prefix) ?
options.Prefix : name,
Ignore = options.Ignore,
IdentifierFile = options.IdentifierFile
}
};
if (!string.IsNullOrWhiteSpace(options.IdentifierFile))
{
string identifierFile = NodesetIdentifierFileValidator.ResolvePath(
file,
options.IdentifierFile,
csvFiles);
if (identifierFile == null)
{
m_identifierValidationErrors.Add(
NodesetIdentifierValidationError.MissingFile(
file,
options.IdentifierFile));
}
else
{
m_identifierFiles.Add((info, identifierFile));
}
}
if (m_nodesets.TryGetValue(model.ModelUri, out NodesetFile existing) &&
existing.Info.Version.CompareTo(info.Info.Version, StringComparison.Ordinal) < 0)
{
info.PreviousVersions = [];
if (existing.PreviousVersions != null)
{
info.PreviousVersions.AddRange(existing.PreviousVersions);
}
existing.PreviousVersions = null;
info.PreviousVersions.Add(existing);
}
m_nodesets[model.ModelUri] = info;
}
catch (Exception ex)
{
if (m_logger.IsEnabled(LogLevel.Critical))
{
m_logger.LogCritical(ex, "Could not parse NodeSet ({File}).", file);
}
}
}
foreach (IGrouping<string, (NodesetFile NodeSet, string IdentifierFile)> group in
m_identifierFiles
.GroupBy(entry => entry.IdentifierFile, StringComparer.Ordinal)
.OrderBy(entry => entry.Key, StringComparer.Ordinal))
{
if (group.Count() > 1)
{
foreach ((NodesetFile nodeset, string identifierFile) in group
.OrderBy(entry => entry.NodeSet.FileName, StringComparer.Ordinal))
{
m_identifierValidationErrors.Add(
NodesetIdentifierValidationError.AssignedToMultipleModels(
nodeset.FileName,
identifierFile));
}
continue;
}
(NodesetFile singleNodeset, string singleIdentifierFile) = group.Single();
m_identifierValidationErrors.AddRange(
NodesetIdentifierFileValidator.Validate(
fileSystem,
singleNodeset.FileName,
singleNodeset.Info.ModelUri,
singleIdentifierFile,
telemetry));
}
}
/// <summary>
/// Get nodeset and dependencies for the model uri
/// </summary>
/// <returns></returns>
public List<string> GetDesignFileListForModel(
string modelUri,
out NodesetFile nodeset,
IReadOnlyDictionary<string, ModelDependencyReference> referencedModels = null)
{
if (!m_nodesets.TryGetValue(modelUri, out nodeset))
{
return null;
}
Dictionary<string, NodesetFile> dependencies = [];
if (!CollectDependencies(nodeset, dependencies, referencedModels))
{
return null;
}
List<string> files = [$"{nodeset.FileName},{nodeset.Info.Prefix},{nodeset.Info.Name}"];
foreach (NodesetFile dependency in dependencies.Values
.Where(x => x.Info.ModelUri != Namespaces.OpcUa))
{
files.Add($"{dependency.FileName},{dependency.Info.Prefix},{dependency.Info.Name}");
}
return files;
}
/// <summary>
/// Collect dependencies
/// </summary>
private bool CollectDependencies(
NodesetFile target,
Dictionary<string, NodesetFile> dependencies,
IReadOnlyDictionary<string, ModelDependencyReference> referencedModels)
{
if (target.NodeSet.NamespaceUris == null)
{
return true;
}
foreach (string ns in target.NodeSet.NamespaceUris)
{
if (dependencies.ContainsKey(ns) || ns == target.Info.ModelUri)
{
continue;
}
if (!m_nodesets.TryGetValue(ns, out NodesetFile nodeset))
{
if (referencedModels != null && referencedModels.ContainsKey(ns))
{
// Dependency is supplied by a referenced assembly's
// generated code; not present in AdditionalFiles is
// expected. Skip without erroring.
if (m_logger.IsEnabled(LogLevel.Information))
{
m_logger.LogInformation(
"NodeSet ({ModelUri}) dependency ({Namespace}) " +
"satisfied by referenced assembly.",
target.Info.ModelUri,
ns);
}
continue;
}
m_logger.LogError(
"NodeSet ({ModelUri}) dependency is missing ({Namespace}).",
target.Info.ModelUri,
ns);
return false;
}
// favour the version in the same directory as the target.
if (nodeset.PreviousVersions != null &&
Path.GetDirectoryName(nodeset.FileName) !=
Path.GetDirectoryName(target.FileName))
{
foreach (NodesetFile ii in nodeset.PreviousVersions)
{
if (Path.GetDirectoryName(ii.FileName) ==
Path.GetDirectoryName(target.FileName))
{
nodeset = ii;
break;
}
}
}
dependencies[ns] = nodeset;
if (!CollectDependencies(nodeset, dependencies, referencedModels))
{
return false;
}
}
return true;
}
private static string GetNameFromUri(string uri)
{
var builder = new Uri(uri);
string path = builder.LocalPath.TrimEnd('/');
if (path.StartsWith("/UA/", StringComparison.OrdinalIgnoreCase))
{
path = path[4..];
}
if (path.StartsWith("/OpcUa/", StringComparison.OrdinalIgnoreCase))
{
path = path[7..];
}
path = path.Trim('/')
.Replace("/", string.Empty, StringComparison.Ordinal)
.Replace('-', '_')
.Replace('+', '_');
int colon = path.LastIndexOf(':');
if (colon != -1)
{
path = path[(colon + 1)..];
}
// Remove invalid path characters
foreach (char c in Path.GetInvalidFileNameChars())
{
path = path.Replace(c, '_');
}
return path;
}
private readonly ILogger m_logger;
private readonly Dictionary<string, NodesetFile> m_nodesets = [];
private readonly List<(NodesetFile NodeSet, string IdentifierFile)> m_identifierFiles = [];
private readonly List<NodesetIdentifierValidationError> m_identifierValidationErrors = [];
}
}