mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-28 22:55:49 +08:00
## Summary Adds OPC UA WoT Connectivity 1.1 to the stack, together with the Types, source generation, Server and xRegistry work it is built on. This branch is the merge point for the fourteen-PR stack that replaced integration PR #4093; every constituent PR was reviewed and approved separately and is listed below. 414 files changed, +108,936 / -3,199. Roughly 46k added lines of product code, 57k of tests, plus samples, tools and documentation. ## Types - Complete `ExtensionObject` raw-body decoding for binary, XML and JSON bodies by resolving the concrete type through the message context's encodeable factory, and simplify the `Variant` structure helpers by delegating to it (#4128). - Add `IAtomicFileReplace` as an optional `IFileSystem` capability so existing external implementations keep working, with atomic publish for `LocalFileSystem` via `File.Replace`/`File.Move` and for `VirtualFileSystem` by re-keying the in-memory entry (#4128). - Add lossless conversion between WoT documents and NodeSet2 (`Opc.Ua.Wot.WotNodeSetConverter`). A byte-exact `uav:nodeSet` envelope is preserved when requested, the structured `uav:nodes` projection is used when the readable vocabulary is incomplete, and NodeSet2 is otherwise synthesized from readable WoT terms. Unmapped JSON members survive a round trip as pointer-addressed residue (#4131). ## Source generation - Improve the NodeSet to ModelDesign conversion and the generated node state (#4132). - Generate OPC UA models directly from WoT Thing Description files (#4134). ## Server - Add live NodeManager shadow and immediate reload. A reload materializes a new generation beside the active one and switches atomically; the superseded generation is retired gracefully so existing MonitoredItems keep being served until they drain, or immediately when the caller asks for it. Includes the request admission and drain machinery that lets an orderly shutdown wait for admitted requests instead of tearing down underneath them (#4147). - Make subscription transfer between sessions transactional, so a failed transfer leaves neither session holding a partially moved subscription (#4150). ## xRegistry - Allow clients to use explicit registry roots rather than assuming a well-known location (#4130). ## WoT Connectivity - Add the WoT Connectivity 1.1 information model: a registry-first revision layered on the abstract xRegistry base model, incorporating the complete published OPC 10100-1 v1.02 surface as deprecated nodes in the same namespace so existing 1.02 clients keep working (#4139). - Add the protocol binding abstractions and planners that compile WoT forms into executable plans (#4140). - Add the HTTP, Modbus, OPC UA and MQTT binding executors (#4144). - Add the registry and its stores, including document versioning, validation, dependency resolution and refresh (#4146). - Add the registry client (#4142). - Materialize WoT Thing Descriptions into the server address space, deriving types from Thing Models and instances from Thing Descriptions (#4154). - Restore the WoT asset registry bridge so the deprecated 1.02 asset surface is backed by the registry (#4171). - Add the WoT Connectivity samples and complete the documentation (#4156), and restore the remaining coverage and docs (#4172). ## New projects `src/Opc.Ua.WotCon.Bindings`, `src/Opc.Ua.WotCon.Bindings.Mqtt`, `tests/Opc.Ua.WotCon.Bindings.Tests`, `tests/Opc.Ua.WotCon.Samples.Tests`, and the `samples/WotCon` AggregationServer, AggregationClient and FlatTagServer samples. ## Constituent pull requests Merged into this branch top-down, each independently approved: | PR | Title | | --- | --- | | #4128 | [Types] Complete ExtensionObject decoding and add an atomic file replace capability | | #4131 | [Types] Add lossless conversion between WoT documents and NodeSet2 | | #4132 | [SourceGeneration] Improve NodeSet to ModelDesign conversion and generated node state | | #4134 | [SourceGeneration] Generate OPC UA models from WoT Thing Description files | | #4147 | [Server] Add live NodeManager shadow and immediate reload | | #4150 | [Server] Make subscription transfer between sessions transactional | | #4130 | [XRegistry] Allow clients to use explicit registry roots | | #4139 | [WotCon] Add the WoT Connectivity 1.1 information model | | #4140 | [WotCon] Add the WoT protocol binding abstractions and planners | | #4144 | [WotCon] Add the HTTP, Modbus, OPC UA and MQTT binding executors | | #4146 | [WotCon] Add the WoT Connectivity registry and its stores | | #4142 | [WotCon] Add the WoT Connectivity registry client | | #4154 | [WotCon] Materialize WoT Thing Descriptions into the server address space | | #4156 | [WotCon] Add the WoT Connectivity samples and complete the documentation | | #4171 | Restore WoT asset registry bridge | | #4172 | Restore WoT coverage and docs | Supersedes #4093. ## Follow-up The WoT Connectivity and WoT Binding drafts have moved to 1.1-draft2 since this work was authored. Aligning with that revision - the removed group vocabulary, the new projection/View construct, the remaining model vocabulary terms and the two new portable-identity validation rules - is tracked separately and will follow in its own pull request. ## Validation Built for every target framework with zero warnings and zero errors, and validated at the tip of the stack with `Opc.Ua.WotCon.Tests` at 999 passed / 0 failed and `Opc.Ua.Server.Tests` at 4024 passed / 0 failed. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8
1991 lines
87 KiB
C#
1991 lines
87 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.IO;
|
|
using System.Linq;
|
|
using Opc.Ua.Schema.Model;
|
|
|
|
namespace Opc.Ua.SourceGeneration
|
|
{
|
|
/// <summary>
|
|
/// Emits a typed, intellisense-aware fluent builder facade for the
|
|
/// model's predefined-instance tree. Drops in alongside the existing
|
|
/// <see cref="NodeManagerGenerator"/> output so users can write
|
|
/// <c>b.Boilers.Boiler__1.DrumX001.LIX001.Output.OnRead(...)</c>
|
|
/// inside their <c>Configure</c> partial.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// One typed wrapper class is emitted per instance design that lives
|
|
/// in the manager's predefined-instance tree. Each wrapper exposes
|
|
/// typed accessor properties for its direct children (resolved via
|
|
/// the design <c>Hierarchy</c>):
|
|
/// </para>
|
|
/// <list type="bullet">
|
|
/// <item><description>variables → <c>IVariableBuilder<T></c>;</description></item>
|
|
/// <item><description>methods → a per-method wrapper class with typed
|
|
/// <c>OnCall(Func<…>)</c> overloads;</description></item>
|
|
/// <item><description>objects → the matching child instance wrapper.</description></item>
|
|
/// </list>
|
|
/// <para>
|
|
/// A top-level <c>I{Manager}NodeManagerBuilder</c> interface (extending
|
|
/// <c>Opc.Ua.Server.Fluent.INodeManagerBuilder</c>) carries one
|
|
/// accessor per top-level predefined instance. The
|
|
/// node-manager generator emits both
|
|
/// <c>Configure(INodeManagerBuilder)</c> and
|
|
/// <c>Configure(I{Manager}NodeManagerBuilder)</c> partials so existing
|
|
/// implementations continue to work unchanged.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class FluentBuilderGenerator : IGenerator
|
|
{
|
|
/// <summary>
|
|
/// Optional override for the manager class name (matches the value
|
|
/// passed to <see cref="NodeManagerGenerator.OverrideClassName"/>).
|
|
/// Defaults to <c>{Prefix}NodeManager</c>.
|
|
/// </summary>
|
|
public string OverrideManagerClassName { get; init; }
|
|
|
|
/// <summary>
|
|
/// Optional override for the manager namespace (matches the value
|
|
/// passed to <see cref="NodeManagerGenerator.OverrideNamespace"/>).
|
|
/// Defaults to the model's target-namespace prefix.
|
|
/// </summary>
|
|
public string OverrideManagerNamespace { get; init; }
|
|
|
|
/// <summary>
|
|
/// When <see langword="true"/>, emit the typed
|
|
/// <c>I{Manager}Builder</c> interface, its typed-builder
|
|
/// implementation, and one wrapper class per top-level
|
|
/// predefined instance. When <see langword="false"/> only the
|
|
/// per-ObjectType IComponentAccessor / IPropertyAccessor
|
|
/// extension classes are emitted (the manager-wrapper output is
|
|
/// scoped to models that ship a generated <c>NodeManager</c> —
|
|
/// NodeSet2-only consumers that just want type-level typed
|
|
/// accessors leave this off). Defaults to <see langword="true"/>
|
|
/// for backward compatibility with existing ModelDesign callers.
|
|
/// </summary>
|
|
public bool GenerateManagerWrappers { get; init; } = true;
|
|
|
|
/// <summary>
|
|
/// When <see langword="true"/> (the default), emit the
|
|
/// per-ObjectType <c>IComponentAccessor<TState></c> /
|
|
/// <c>IPropertyAccessor<TState></c> extension classes.
|
|
/// Set to <see langword="false"/> when the consumer assembly
|
|
/// does not reference <c>Opc.Ua.Server</c> (the emitted
|
|
/// emitted by <see cref="FluentBuilderGenerator"/> is allowed.
|
|
/// The marker interfaces themselves live in
|
|
/// <c>Opc.Ua.Types</c> (Core); the emitted method bodies
|
|
/// reference <c>Opc.Ua.Server.Fluent.INodeBuilder<T></c>
|
|
/// and would fail to compile there).
|
|
/// </summary>
|
|
public bool EmitFluentAccessors { get; init; } = true;
|
|
|
|
/// <summary>
|
|
/// Initializes a new <see cref="FluentBuilderGenerator"/>.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException"><paramref name="context"/> is null.</exception>
|
|
public FluentBuilderGenerator(IGeneratorContext context)
|
|
{
|
|
m_context = context ?? throw new ArgumentNullException(nameof(context));
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public IEnumerable<Resource> Emit()
|
|
{
|
|
string nsPrefix = m_context.ModelDesign.TargetNamespace.Prefix;
|
|
string typeStem = nsPrefix.Replace(".", string.Empty, StringComparison.Ordinal);
|
|
string outputNamespace = string.IsNullOrEmpty(OverrideManagerNamespace)
|
|
? nsPrefix
|
|
: OverrideManagerNamespace;
|
|
string managerClassName = string.IsNullOrEmpty(OverrideManagerClassName)
|
|
? typeStem + "NodeManager"
|
|
: OverrideManagerClassName;
|
|
string interfaceName = "I" + managerClassName + "Builder";
|
|
string typedBuilderClassName = managerClassName + "TypedBuilder";
|
|
|
|
// Discover the top-level predefined instances. We always emit
|
|
// the typed manager interface and proxy class — even for
|
|
// models with no predefined instances — because the
|
|
// source-generated NodeManager partial unconditionally
|
|
// references them.
|
|
List<InstanceDesign> roots = GetTopLevelInstances();
|
|
|
|
// Walk every instance under each root and assemble the wrapper
|
|
// class metadata. m_wrappers is keyed by SymbolicId path so
|
|
// child accessors can resolve their target wrapper by id.
|
|
m_wrappers = [];
|
|
m_methodWrappers = [];
|
|
foreach (InstanceDesign root in roots)
|
|
{
|
|
CollectInstanceWrappers(root);
|
|
}
|
|
|
|
// Detect naming collisions per containing wrapper. Fail with a
|
|
// diagnostic (mirrored as an InvalidOperationException since we
|
|
// are running outside the Roslyn diagnostic pipeline here).
|
|
ValidateNoCollisions();
|
|
|
|
// Wire each wrapper to its direct child object/method
|
|
// wrappers so the recursive emitter can walk the tree
|
|
// depth-first and emit nested type declarations.
|
|
LinkChildWrappers();
|
|
|
|
string fileName = Path.Combine(
|
|
m_context.OutputFolder,
|
|
CoreUtils.Format("{0}.FluentBuilders.g.cs",
|
|
string.IsNullOrEmpty(OverrideManagerClassName)
|
|
? nsPrefix
|
|
: OverrideManagerClassName));
|
|
|
|
using TextWriter writer = m_context.FileSystem.CreateTextWriter(fileName);
|
|
using var templateWriter = new TemplateWriter(writer);
|
|
var template = new Template(templateWriter, FluentBuilderTemplates.File);
|
|
|
|
template.AddReplacement(
|
|
Tokens.ModelUri,
|
|
EscapeStringLiteral(m_context.ModelDesign.TargetNamespace.Value));
|
|
template.AddReplacement(Tokens.NamespacePrefix, outputNamespace);
|
|
|
|
// Render the typed manager interface, the typed manager
|
|
// implementation, and one wrapper class per instance / method
|
|
// into the body slot. The body is emitted by a single
|
|
// load-template callback (the templating engine treats the
|
|
// returned null TemplateString as "nothing more to emit").
|
|
object[] bodyTargets = ["body"];
|
|
template.AddReplacement(
|
|
Tokens.ListOfTypes,
|
|
bodyTargets,
|
|
onLoad: ctx =>
|
|
{
|
|
if (GenerateManagerWrappers)
|
|
{
|
|
EmitManagerInterface(ctx.Out, interfaceName, roots);
|
|
EmitTypedManagerImpl(
|
|
ctx.Out,
|
|
interfaceName,
|
|
typedBuilderClassName,
|
|
managerClassName,
|
|
roots);
|
|
|
|
// Walk top-level instance wrappers depth-first so each
|
|
// child object/method wrapper is emitted as a nested
|
|
// type inside its parent. Top-level wrappers (those
|
|
// whose parent path is empty) live at namespace scope.
|
|
foreach (InstanceWrapper top in m_wrappers.Values
|
|
.Where(w => w.ParentKey == null)
|
|
.OrderBy(w => w.LeafName, StringComparer.Ordinal))
|
|
{
|
|
EmitInstanceWrapper(ctx.Out, top, indent: string.Empty);
|
|
}
|
|
}
|
|
|
|
// Emit per-ObjectType IComponentAccessor /
|
|
// IPropertyAccessor extension classes so consumers
|
|
// can walk a typed INodeBuilder<TState> tree
|
|
// without spelling out browse paths or
|
|
// QualifiedNames. Always runs (independent of
|
|
// GenerateManagerWrappers) so NodeSet2-only
|
|
// consumers still get the typed accessors.
|
|
// Suppressed when the consumer assembly does not
|
|
// reference Opc.Ua.Server (controlled by the
|
|
// EmitFluentAccessors flag).
|
|
if (EmitFluentAccessors)
|
|
{
|
|
EmitObjectTypeAccessors(ctx.Out);
|
|
}
|
|
return null;
|
|
});
|
|
|
|
template.Render();
|
|
return [fileName.AsTextFileResource()];
|
|
}
|
|
|
|
// Discovery
|
|
/// <summary>
|
|
/// Returns the model's top-level instance designs — those whose
|
|
/// <see cref="NodeDesign.Parent"/> is null and which sit in the
|
|
/// manager's predefined-instance tree (instances, not types).
|
|
/// </summary>
|
|
private List<InstanceDesign> GetTopLevelInstances()
|
|
{
|
|
var result = new List<InstanceDesign>();
|
|
foreach (NodeDesign node in m_context.ModelDesign.GetNodeDesigns())
|
|
{
|
|
if (node is not InstanceDesign instance)
|
|
{
|
|
continue;
|
|
}
|
|
if (instance.Parent != null)
|
|
{
|
|
continue;
|
|
}
|
|
if (m_context.ModelDesign.IsExcluded(instance))
|
|
{
|
|
continue;
|
|
}
|
|
if (instance.NotInAddressSpace)
|
|
{
|
|
continue;
|
|
}
|
|
if (instance.IsDeclaration)
|
|
{
|
|
continue;
|
|
}
|
|
if (instance.Hierarchy == null)
|
|
{
|
|
continue;
|
|
}
|
|
result.Add(instance);
|
|
}
|
|
result.Sort(static (a, b) => string.CompareOrdinal(
|
|
a.SymbolicId?.Name,
|
|
b.SymbolicId?.Name));
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Walks <paramref name="root"/>'s hierarchy and registers a
|
|
/// wrapper for every non-method instance plus a method wrapper for
|
|
/// every method.
|
|
/// </summary>
|
|
private void CollectInstanceWrappers(InstanceDesign root)
|
|
{
|
|
if (root.Hierarchy == null)
|
|
{
|
|
return;
|
|
}
|
|
// Build a parent-path → list of direct children mapping. The
|
|
// hierarchy keys are constructed by joining segments with
|
|
// <see cref="NodeDesign.PathChar"/>; segment names themselves
|
|
// can contain underscores (e.g. <c>Boiler__1</c> for the
|
|
// browse name <c>Boiler #1</c>) so we cannot naively split on
|
|
// the path char. Instead we derive the parent path by stripping
|
|
// the child node's own <c>SymbolicName.Name</c> (plus the
|
|
// separator) from its <c>RelativePath</c> — the same trick
|
|
// used by <see cref="NodeStateGenerator"/>.
|
|
var directChildren = new Dictionary<string, List<HierarchyNode>>(StringComparer.Ordinal);
|
|
foreach (KeyValuePair<string, HierarchyNode> entry in root.Hierarchy.Nodes)
|
|
{
|
|
string path = entry.Key ?? string.Empty;
|
|
if (path.Length == 0)
|
|
{
|
|
continue;
|
|
}
|
|
HierarchyNode hnode = entry.Value;
|
|
if (hnode?.Instance?.SymbolicName?.Name is not string segment ||
|
|
segment.Length == 0)
|
|
{
|
|
continue;
|
|
}
|
|
int trimLen = segment.Length + 1;
|
|
string parent;
|
|
if (path.Length == segment.Length)
|
|
{
|
|
// Top-level child of the root (no preceding parent path).
|
|
parent = string.Empty;
|
|
}
|
|
else if (path.Length < trimLen)
|
|
{
|
|
// Malformed — skip.
|
|
continue;
|
|
}
|
|
else
|
|
{
|
|
parent = path[..^trimLen];
|
|
}
|
|
if (!directChildren.TryGetValue(parent, out List<HierarchyNode> bucket))
|
|
{
|
|
bucket = [];
|
|
directChildren[parent] = bucket;
|
|
}
|
|
bucket.Add(hnode);
|
|
}
|
|
|
|
// Emit one wrapper per non-method instance (root + descendants).
|
|
foreach (KeyValuePair<string, HierarchyNode> entry in root.Hierarchy.Nodes)
|
|
{
|
|
if (entry.Value?.Instance is not NodeDesign nd)
|
|
{
|
|
continue;
|
|
}
|
|
if (m_context.ModelDesign.IsExcluded(nd))
|
|
{
|
|
continue;
|
|
}
|
|
// Skip method instances — methods get their own wrapper
|
|
// class, registered separately below.
|
|
if (entry.Value.Instance is MethodDesign method)
|
|
{
|
|
RegisterMethodWrapper(root, entry.Key, method);
|
|
continue;
|
|
}
|
|
// Skip variables — they are surfaced as IVariableBuilder<T>
|
|
// accessors on their parent wrapper, not as standalone
|
|
// wrapper classes.
|
|
if (entry.Value.Instance is VariableDesign)
|
|
{
|
|
continue;
|
|
}
|
|
if (entry.Value.Instance is not ObjectDesign)
|
|
{
|
|
continue;
|
|
}
|
|
RegisterInstanceWrapper(root, entry.Key, entry.Value, directChildren);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers an <c>InstanceWrapper</c> for the supplied
|
|
/// node. Idempotent — if the wrapper has already been registered
|
|
/// (e.g. via a sibling root) the existing entry is reused.
|
|
/// </summary>
|
|
private void RegisterInstanceWrapper(
|
|
InstanceDesign root,
|
|
string relativePath,
|
|
HierarchyNode hnode,
|
|
Dictionary<string, List<HierarchyNode>> directChildren)
|
|
{
|
|
string key = ComposeKey(root, relativePath);
|
|
if (m_wrappers.ContainsKey(key))
|
|
{
|
|
return;
|
|
}
|
|
|
|
string leafName = ResolveLeafName(root, relativePath, hnode.Instance);
|
|
string parentKey = ResolveParentKey(root, relativePath, leafName);
|
|
string className = ComposeWrapperClassName(leafName, suffix: "Builder");
|
|
var wrapper = new InstanceWrapper
|
|
{
|
|
Key = key,
|
|
ClassName = className,
|
|
LeafName = leafName,
|
|
ParentKey = parentKey,
|
|
NodeStateType = ResolveStateClrType(hnode.Instance),
|
|
BrowseNamespaceUri = ResolveNodeBrowseNamespace(hnode.Instance),
|
|
SupportsPublish = QualifiesAsEventNotifier(hnode.Instance),
|
|
Children = [],
|
|
ChildObjectKeys = [],
|
|
ChildMethodKeys = []
|
|
};
|
|
|
|
// Children resolved relative to this node.
|
|
if (directChildren.TryGetValue(relativePath, out List<HierarchyNode> kids))
|
|
{
|
|
foreach (HierarchyNode kid in kids)
|
|
{
|
|
if (kid?.Instance == null || m_context.ModelDesign.IsExcluded(kid.Instance))
|
|
{
|
|
continue;
|
|
}
|
|
string childKey = ComposeKey(root, kid.RelativePath);
|
|
string accessorName = GetAccessorName(kid.Instance);
|
|
string browseName = GetBrowseName(kid.Instance);
|
|
string browseNsUri = ResolveNodeBrowseNamespace(kid.Instance);
|
|
string childLeaf = ResolveLeafName(root, kid.RelativePath, kid.Instance);
|
|
var child = new ChildAccessor
|
|
{
|
|
AccessorName = accessorName,
|
|
BrowseName = browseName,
|
|
BrowseNamespaceUri = browseNsUri
|
|
};
|
|
|
|
switch (kid.Instance)
|
|
{
|
|
case VariableDesign var:
|
|
child.Kind = ChildKind.Variable;
|
|
child.ValueClrType = GetVariableValueClrType(var);
|
|
break;
|
|
case MethodDesign:
|
|
child.Kind = ChildKind.Method;
|
|
// Lexical scope: methods are emitted as nested
|
|
// classes inside the parent wrapper, so the
|
|
// simple leaf name resolves correctly here.
|
|
child.WrapperClassName = ComposeWrapperClassName(
|
|
childLeaf, suffix: "MethodBuilder");
|
|
break;
|
|
case ObjectDesign:
|
|
child.Kind = ChildKind.Object;
|
|
// Lexical scope: object child wrappers are
|
|
// nested classes; the simple leaf name resolves
|
|
// through the enclosing parent wrapper.
|
|
child.WrapperClassName = ComposeWrapperClassName(
|
|
childLeaf, suffix: "Builder");
|
|
child.ChildKey = childKey;
|
|
child.ChildStateType = ResolveStateClrType(kid.Instance);
|
|
break;
|
|
default:
|
|
// Properties / unknowns: skip for v1.
|
|
continue;
|
|
}
|
|
wrapper.Children.Add(child);
|
|
}
|
|
}
|
|
|
|
m_wrappers[key] = wrapper;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers a <c>MethodWrapper</c> for the supplied method
|
|
/// design. Resolves typed argument shapes from the method's
|
|
/// <c>InputArguments</c>/<c>OutputArguments</c>.
|
|
/// </summary>
|
|
private void RegisterMethodWrapper(
|
|
InstanceDesign root,
|
|
string relativePath,
|
|
MethodDesign method)
|
|
{
|
|
string key = ComposeKey(root, relativePath);
|
|
if (m_methodWrappers.ContainsKey(key))
|
|
{
|
|
return;
|
|
}
|
|
|
|
string leafName = ResolveLeafName(root, relativePath, method);
|
|
string parentKey = ResolveParentKey(root, relativePath, leafName);
|
|
string className = ComposeWrapperClassName(leafName, suffix: "MethodBuilder");
|
|
MethodDesign effectiveMethod = method.IsOverridden()
|
|
? (MethodDesign)method.GetMergedInstance()
|
|
: method;
|
|
(Parameter[] inputs, Parameter[] outputs) =
|
|
MethodDesignArgumentResolver.ResolveMethodArguments(effectiveMethod);
|
|
m_methodWrappers[key] = new MethodWrapper
|
|
{
|
|
Key = key,
|
|
ClassName = className,
|
|
LeafName = leafName,
|
|
ParentKey = parentKey,
|
|
Inputs = inputs,
|
|
Outputs = outputs
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wires each wrapper to its direct child object/method wrappers
|
|
/// so the recursive emitter can walk the tree depth-first. Sorts
|
|
/// siblings by leaf name (ordinal) so generation is deterministic.
|
|
/// </summary>
|
|
private void LinkChildWrappers()
|
|
{
|
|
foreach (InstanceWrapper child in m_wrappers.Values)
|
|
{
|
|
if (child.ParentKey == null)
|
|
{
|
|
continue;
|
|
}
|
|
if (m_wrappers.TryGetValue(child.ParentKey, out InstanceWrapper parent))
|
|
{
|
|
parent.ChildObjectKeys.Add(child.Key);
|
|
}
|
|
}
|
|
foreach (MethodWrapper method in m_methodWrappers.Values)
|
|
{
|
|
if (method.ParentKey == null)
|
|
{
|
|
continue;
|
|
}
|
|
if (m_wrappers.TryGetValue(method.ParentKey, out InstanceWrapper parent))
|
|
{
|
|
parent.ChildMethodKeys.Add(method.Key);
|
|
}
|
|
}
|
|
foreach (InstanceWrapper wrapper in m_wrappers.Values)
|
|
{
|
|
wrapper.ChildObjectKeys.Sort((a, b) => string.CompareOrdinal(
|
|
m_wrappers[a].LeafName,
|
|
m_wrappers[b].LeafName));
|
|
wrapper.ChildMethodKeys.Sort((a, b) => string.CompareOrdinal(
|
|
m_methodWrappers[a].LeafName,
|
|
m_methodWrappers[b].LeafName));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that no two children of the same wrapper sanitize to
|
|
/// the same C# accessor identifier.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException">Two children of the same wrapper resolve to the same C# accessor name.</exception>
|
|
private void ValidateNoCollisions()
|
|
{
|
|
foreach (InstanceWrapper wrapper in m_wrappers.Values)
|
|
{
|
|
var seen = new Dictionary<string, ChildAccessor>(StringComparer.Ordinal);
|
|
foreach (ChildAccessor child in wrapper.Children)
|
|
{
|
|
if (seen.TryGetValue(child.AccessorName, out ChildAccessor existing))
|
|
{
|
|
throw new InvalidOperationException(CoreUtils.Format(
|
|
"Fluent builder generation: children '{0}' and '{1}' on '{2}' both sanitize to the same C# accessor '{3}'. Rename one of the children in the design.",
|
|
existing.BrowseName,
|
|
child.BrowseName,
|
|
wrapper.ClassName,
|
|
child.AccessorName));
|
|
}
|
|
seen[child.AccessorName] = child;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Emission
|
|
/// <summary>
|
|
/// Emits the typed manager interface declaration with one accessor
|
|
/// per top-level predefined instance.
|
|
/// </summary>
|
|
private void EmitManagerInterface(
|
|
ITemplateWriter writer,
|
|
string interfaceName,
|
|
IReadOnlyList<InstanceDesign> roots)
|
|
{
|
|
writer.WriteLine();
|
|
writer.WriteLine("/// <summary>");
|
|
writer.WriteLine(
|
|
"/// Source-generated typed sibling of" +
|
|
" <see cref=\"global::Opc.Ua.Server.Fluent.INodeManagerBuilder\"/>");
|
|
writer.WriteLine(
|
|
"/// that surfaces typed accessors for the predefined-instance tree.");
|
|
writer.WriteLine("/// </summary>");
|
|
writer.WriteLine(
|
|
"[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{0}\", \"{1}\")]",
|
|
ToolName,
|
|
ToolVersion);
|
|
writer.WriteLine("internal interface {0} : global::Opc.Ua.Server.Fluent.INodeManagerBuilder", interfaceName);
|
|
writer.WriteLine("{");
|
|
foreach (InstanceDesign root in roots)
|
|
{
|
|
string accessor = GetAccessorName(root);
|
|
string wrapperKey = ComposeKey(root, string.Empty);
|
|
if (!m_wrappers.TryGetValue(wrapperKey, out InstanceWrapper wrapper))
|
|
{
|
|
continue;
|
|
}
|
|
writer.WriteLine(" /// <summary>Resolves the predefined instance <c>{0}</c>.</summary>",
|
|
EscapeXmlDoc(GetBrowseName(root)));
|
|
writer.WriteLine(" {0} {1} {{ get; }}", wrapper.ClassName, accessor);
|
|
}
|
|
writer.WriteLine("}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits the internal sealed implementation that wraps the runtime
|
|
/// fluent <c>NodeManagerBuilder</c> and adds typed top-level
|
|
/// accessors.
|
|
/// </summary>
|
|
private void EmitTypedManagerImpl(
|
|
ITemplateWriter writer,
|
|
string interfaceName,
|
|
string className,
|
|
string managerClassName,
|
|
IReadOnlyList<InstanceDesign> roots)
|
|
{
|
|
writer.WriteLine();
|
|
writer.WriteLine("/// <summary>");
|
|
writer.WriteLine(
|
|
"/// Internal proxy that wraps the runtime fluent" +
|
|
" <c>NodeManagerBuilder</c>");
|
|
writer.WriteLine("/// to surface the typed <see cref=\"{0}\"/> facade.", interfaceName);
|
|
writer.WriteLine("/// </summary>");
|
|
writer.WriteLine(
|
|
"[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{0}\", \"{1}\")]",
|
|
ToolName,
|
|
ToolVersion);
|
|
writer.WriteLine("internal sealed class {0} : {1}", className, interfaceName);
|
|
writer.WriteLine("{");
|
|
writer.WriteLine(
|
|
" private readonly global::Opc.Ua.Server.Fluent.INodeManagerBuilder __inner;");
|
|
writer.WriteLine();
|
|
writer.WriteLine(
|
|
" internal {0}(global::Opc.Ua.Server.Fluent.INodeManagerBuilder inner)",
|
|
className);
|
|
writer.WriteLine(" {");
|
|
writer.WriteLine(
|
|
" __inner = inner ?? throw new global::System.ArgumentNullException(nameof(inner));");
|
|
writer.WriteLine(" }");
|
|
writer.WriteLine();
|
|
|
|
// Pass-through INodeManagerBuilder members. Each line is broken
|
|
// explicitly to satisfy the repository line-length analyzer
|
|
// (RCS0056, max 120 chars).
|
|
EmitPassThroughProperty(writer, "global::Opc.Ua.ISystemContext", "Context");
|
|
EmitPassThroughProperty(writer, "global::Opc.Ua.Server.IAsyncNodeManager", "NodeManager");
|
|
EmitPassThroughProperty(writer, "global::Opc.Ua.Server.Fluent.IFluentDispatcher", "Dispatcher");
|
|
|
|
EmitPassThroughMethod(writer,
|
|
"global::Opc.Ua.Server.Fluent.INodeBuilder", "Node",
|
|
"string browsePath", "browsePath");
|
|
EmitPassThroughGenericMethod(writer,
|
|
"global::Opc.Ua.Server.Fluent.INodeBuilder<TState>", "Node",
|
|
"string browsePath", "browsePath");
|
|
EmitPassThroughMethod(writer,
|
|
"global::Opc.Ua.Server.Fluent.INodeBuilder", "Node",
|
|
"global::Opc.Ua.NodeId nodeId", "nodeId");
|
|
EmitPassThroughGenericMethod(writer,
|
|
"global::Opc.Ua.Server.Fluent.INodeBuilder<TState>", "Node",
|
|
"global::Opc.Ua.NodeId nodeId", "nodeId");
|
|
|
|
EmitPassThroughMethod(writer,
|
|
"global::Opc.Ua.Server.Fluent.INodeBuilder", "NodeFromTypeId",
|
|
"global::Opc.Ua.NodeId typeDefinitionId", "typeDefinitionId");
|
|
EmitPassThroughMethod(writer,
|
|
"global::Opc.Ua.Server.Fluent.INodeBuilder", "NodeFromTypeId",
|
|
"global::Opc.Ua.NodeId typeDefinitionId, global::Opc.Ua.QualifiedName browseName",
|
|
"typeDefinitionId, browseName");
|
|
EmitPassThroughGenericMethod(writer,
|
|
"global::Opc.Ua.Server.Fluent.INodeBuilder<TState>", "NodeFromTypeId",
|
|
"global::Opc.Ua.NodeId typeDefinitionId", "typeDefinitionId");
|
|
EmitPassThroughGenericMethod(writer,
|
|
"global::Opc.Ua.Server.Fluent.INodeBuilder<TState>", "NodeFromTypeId",
|
|
"global::Opc.Ua.NodeId typeDefinitionId, global::Opc.Ua.QualifiedName browseName",
|
|
"typeDefinitionId, browseName");
|
|
|
|
EmitPassThroughGenericMethod(writer,
|
|
"global::Opc.Ua.Server.Fluent.IVariableBuilder<TValue>", "Variable",
|
|
"string browsePath", "browsePath", typeArg: "TValue", noConstraint: true);
|
|
EmitPassThroughGenericMethod(writer,
|
|
"global::Opc.Ua.Server.Fluent.IVariableBuilder<TValue>", "Variable",
|
|
"global::Opc.Ua.NodeId nodeId", "nodeId", typeArg: "TValue", noConstraint: true);
|
|
EmitPassThroughGenericMethod(writer,
|
|
"global::Opc.Ua.Server.Fluent.IVariableBuilder<TValue>", "VariableFromTypeId",
|
|
"global::Opc.Ua.NodeId typeDefinitionId", "typeDefinitionId",
|
|
typeArg: "TValue", noConstraint: true);
|
|
EmitPassThroughGenericMethod(writer,
|
|
"global::Opc.Ua.Server.Fluent.IVariableBuilder<TValue>", "VariableFromTypeId",
|
|
"global::Opc.Ua.NodeId typeDefinitionId, global::Opc.Ua.QualifiedName browseName",
|
|
"typeDefinitionId, browseName",
|
|
typeArg: "TValue", noConstraint: true);
|
|
EmitPassThroughGenericMethod(writer,
|
|
"global::Opc.Ua.Server.Fluent.IVariableBuilder<TValue>", "VariableFromDataTypeId",
|
|
"global::Opc.Ua.NodeId dataTypeId", "dataTypeId",
|
|
typeArg: "TValue", noConstraint: true);
|
|
EmitPassThroughGenericMethod(writer,
|
|
"global::Opc.Ua.Server.Fluent.IVariableBuilder<TValue>", "VariableFromDataTypeId",
|
|
"global::Opc.Ua.NodeId dataTypeId, global::Opc.Ua.QualifiedName browseName",
|
|
"dataTypeId, browseName",
|
|
typeArg: "TValue", noConstraint: true);
|
|
|
|
// Typed top-level accessors.
|
|
foreach (InstanceDesign root in roots)
|
|
{
|
|
string accessor = GetAccessorName(root);
|
|
string wrapperKey = ComposeKey(root, string.Empty);
|
|
if (!m_wrappers.TryGetValue(wrapperKey, out InstanceWrapper wrapper))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
_ = GetBrowseName(root);
|
|
string nsUri = ResolveNodeBrowseNamespace(root);
|
|
writer.WriteLine();
|
|
writer.WriteLine(" /// <inheritdoc/>");
|
|
writer.WriteLine(" public {0} {1}", wrapper.ClassName, accessor);
|
|
writer.WriteLine(" {");
|
|
writer.WriteLine(" get");
|
|
writer.WriteLine(" {");
|
|
writer.WriteLine(" ushort __ns = __inner.Context.NamespaceUris.GetIndexOrAppend(\"{0}\");",
|
|
EscapeStringLiteral(nsUri));
|
|
writer.WriteLine(" return new {0}(__inner.Node<{1}>(new global::Opc.Ua.NodeId({2}, __ns)));",
|
|
wrapper.ClassName,
|
|
wrapper.NodeStateType,
|
|
EmitNodeIdConstructorArg(root));
|
|
writer.WriteLine(" }");
|
|
writer.WriteLine(" }");
|
|
}
|
|
|
|
writer.WriteLine("}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits a <c>{Type} {Name} => __inner.{Name};</c> property forwarder
|
|
/// across two output lines so the result fits within RCS0056's 120
|
|
/// character ceiling for any plausible runtime type name.
|
|
/// </summary>
|
|
private static void EmitPassThroughProperty(
|
|
ITemplateWriter writer,
|
|
string returnType,
|
|
string memberName)
|
|
{
|
|
writer.WriteLine();
|
|
writer.WriteLine(" /// <inheritdoc/>");
|
|
writer.WriteLine(" public {0} {1}", returnType, memberName);
|
|
writer.WriteLine(" => __inner.{0};", memberName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits a non-generic pass-through method forwarder broken across
|
|
/// multiple lines (signature, opener, body, closer).
|
|
/// </summary>
|
|
private static void EmitPassThroughMethod(
|
|
ITemplateWriter writer,
|
|
string returnType,
|
|
string memberName,
|
|
string parameterList,
|
|
string callArguments)
|
|
{
|
|
writer.WriteLine();
|
|
writer.WriteLine(" /// <inheritdoc/>");
|
|
writer.WriteLine(" public {0} {1}({2})", returnType, memberName, parameterList);
|
|
writer.WriteLine(" => __inner.{0}({1});", memberName, callArguments);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits a generic pass-through method forwarder broken across
|
|
/// multiple lines. Defaults to <c>where TState : NodeState</c>;
|
|
/// pass <paramref name="noConstraint"/> = <c>true</c> to suppress
|
|
/// the constraint and <paramref name="typeArg"/> to use a custom
|
|
/// type-parameter name (e.g. <c>TValue</c>).
|
|
/// </summary>
|
|
private static void EmitPassThroughGenericMethod(
|
|
ITemplateWriter writer,
|
|
string returnType,
|
|
string memberName,
|
|
string parameterList,
|
|
string callArguments,
|
|
string typeArg = "TState",
|
|
bool noConstraint = false)
|
|
{
|
|
writer.WriteLine();
|
|
writer.WriteLine(" /// <inheritdoc/>");
|
|
writer.WriteLine(" public {0} {1}<{2}>({3})",
|
|
returnType, memberName, typeArg, parameterList);
|
|
if (!noConstraint)
|
|
{
|
|
writer.WriteLine(" where {0} : global::Opc.Ua.NodeState", typeArg);
|
|
}
|
|
writer.WriteLine(" => __inner.{0}<{1}>({2});",
|
|
memberName, typeArg, callArguments);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits one wrapper class for an <see cref="InstanceDesign"/>
|
|
/// describing a non-method instance. The class is rendered at the
|
|
/// indentation depth supplied by <paramref name="indent"/>; child
|
|
/// object/method wrappers are emitted recursively as nested types
|
|
/// one level deeper.
|
|
/// </summary>
|
|
private void EmitInstanceWrapper(
|
|
ITemplateWriter writer,
|
|
InstanceWrapper wrapper,
|
|
string indent)
|
|
{
|
|
string memberIndent = indent + Indent;
|
|
|
|
writer.WriteLine();
|
|
writer.WriteLine("{0}/// <summary>Typed wrapper for the predefined instance.</summary>", indent);
|
|
writer.WriteLine(
|
|
"{0}[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{1}\", \"{2}\")]",
|
|
indent,
|
|
ToolName,
|
|
ToolVersion);
|
|
writer.WriteLine("{0}internal sealed class {1}", indent, wrapper.ClassName);
|
|
writer.WriteLine("{0}{{", indent);
|
|
writer.WriteLine("{0}private readonly global::Opc.Ua.Server.Fluent.INodeBuilder<{1}> __node;",
|
|
memberIndent, wrapper.NodeStateType);
|
|
writer.WriteLine();
|
|
writer.WriteLine("{0}internal {1}(global::Opc.Ua.Server.Fluent.INodeBuilder<{2}> node)",
|
|
memberIndent, wrapper.ClassName, wrapper.NodeStateType);
|
|
writer.WriteLine("{0}{{", memberIndent);
|
|
writer.WriteLine("{0}__node = node ?? throw new global::System.ArgumentNullException(nameof(node));",
|
|
memberIndent + Indent);
|
|
writer.WriteLine("{0}}}", memberIndent);
|
|
writer.WriteLine();
|
|
writer.WriteLine("{0}/// <summary>Underlying typed node builder.</summary>", memberIndent);
|
|
writer.WriteLine("{0}public global::Opc.Ua.Server.Fluent.INodeBuilder<{1}> Builder => __node;",
|
|
memberIndent, wrapper.NodeStateType);
|
|
writer.WriteLine();
|
|
writer.WriteLine("{0}/// <summary>Resolved underlying node.</summary>", memberIndent);
|
|
writer.WriteLine("{0}public {1} Node => __node.Node;", memberIndent, wrapper.NodeStateType);
|
|
|
|
foreach (ChildAccessor child in wrapper.Children)
|
|
{
|
|
EmitChildAccessor(writer, child, memberIndent);
|
|
}
|
|
|
|
if (wrapper.SupportsPublish)
|
|
|
|
{
|
|
EmitPublishOverloads(writer, wrapper, memberIndent);
|
|
}
|
|
// Emit the nested method wrappers, then the nested object
|
|
// wrappers. Sibling order is leaf-name ordinal (set up by
|
|
// LinkChildWrappers) so generation is deterministic.
|
|
foreach (string methodKey in wrapper.ChildMethodKeys)
|
|
{
|
|
if (m_methodWrappers.TryGetValue(methodKey, out MethodWrapper nestedMethod))
|
|
{
|
|
EmitMethodWrapper(writer, nestedMethod, memberIndent);
|
|
}
|
|
}
|
|
foreach (string childKey in wrapper.ChildObjectKeys)
|
|
{
|
|
if (m_wrappers.TryGetValue(childKey, out InstanceWrapper nested))
|
|
{
|
|
EmitInstanceWrapper(writer, nested, memberIndent);
|
|
}
|
|
}
|
|
|
|
writer.WriteLine("{0}}}", indent);
|
|
}
|
|
|
|
// Per-ObjectType IComponentAccessor / IPropertyAccessor
|
|
/// <summary>
|
|
/// Emits one pair of static partial extension classes per
|
|
/// concrete (non-abstract) ObjectType in the model. The
|
|
/// extensions hang off
|
|
/// <c>Opc.Ua.IComponentAccessor<TState></c>
|
|
/// and <c>Opc.Ua.IPropertyAccessor<TState></c>
|
|
/// (Core-side markers, available to model-only assemblies)
|
|
/// so authors can walk a typed <c>INodeStateBuilder<TState></c>
|
|
/// tree via chained <c>.Components()</c> / <c>.Properties()</c>
|
|
/// pivots without spelling out browse-paths or QualifiedNames.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Children are classified by their design-schema type:
|
|
/// </para>
|
|
/// <list type="bullet">
|
|
/// <item><description><see cref="PropertyDesign"/> →
|
|
/// <c>IPropertyAccessor<TState></c> extension returning
|
|
/// <c>IVariableBuilder<TValue></c>.</description></item>
|
|
/// <item><description><see cref="ObjectDesign"/>,
|
|
/// <see cref="MethodDesign"/>, and non-property
|
|
/// <see cref="VariableDesign"/> →
|
|
/// <c>IComponentAccessor<TState></c> extension returning
|
|
/// typed <c>INodeBuilder<TChildState></c>.</description></item>
|
|
/// </list>
|
|
/// <para>
|
|
/// Each accessor method uses an unqualified
|
|
/// <c>QualifiedName(name)</c> at the call site; the runtime
|
|
/// <c>NodeBuilder</c> cross-namespace fallback resolves the
|
|
/// child even when it carries a different namespace than the
|
|
/// parent (e.g. PumpType inheriting Operational from
|
|
/// MachineComponentType).
|
|
/// </para>
|
|
/// </remarks>
|
|
private void EmitObjectTypeAccessors(ITemplateWriter writer)
|
|
{
|
|
foreach (NodeDesign node in m_context.ModelDesign.GetNodeDesigns())
|
|
{
|
|
if (node is not ObjectTypeDesign objectType ||
|
|
m_context.ModelDesign.IsExcluded(objectType))
|
|
{
|
|
continue;
|
|
}
|
|
if (objectType.IsAbstract)
|
|
{
|
|
continue;
|
|
}
|
|
// Only emit accessors for types defined in this model —
|
|
// accessors for types declared in referenced models are
|
|
// emitted by those models' own generated source.
|
|
if (!string.Equals(objectType.SymbolicName?.Namespace,
|
|
m_context.ModelDesign.TargetNamespace?.Value,
|
|
StringComparison.Ordinal))
|
|
{
|
|
continue;
|
|
}
|
|
InstanceDesign[] children = objectType.Children?.Items;
|
|
if (children == null || children.Length == 0)
|
|
{
|
|
continue;
|
|
}
|
|
string parentClr = ResolveObjectTypeStateClr(objectType);
|
|
if (string.IsNullOrEmpty(parentClr))
|
|
{
|
|
continue;
|
|
}
|
|
string typeStem = objectType.SymbolicName?.Name
|
|
?? string.Empty;
|
|
if (typeStem.EndsWith("Type", StringComparison.Ordinal))
|
|
{
|
|
typeStem = typeStem[..^"Type".Length];
|
|
}
|
|
|
|
var components = new List<InstanceDesign>();
|
|
var properties = new List<InstanceDesign>();
|
|
foreach (InstanceDesign child in children)
|
|
{
|
|
if (child == null || m_context.ModelDesign.IsExcluded(child))
|
|
{
|
|
continue;
|
|
}
|
|
if (child is PropertyDesign)
|
|
{
|
|
properties.Add(child);
|
|
}
|
|
else
|
|
{
|
|
components.Add(child);
|
|
}
|
|
}
|
|
|
|
if (components.Count > 0)
|
|
{
|
|
EmitTypeAccessorClass(
|
|
writer,
|
|
accessorKind: "Component",
|
|
accessorIface: "global::Opc.Ua.IComponentAccessor",
|
|
className: typeStem + "StateComponents",
|
|
parentClr: parentClr,
|
|
children: components);
|
|
}
|
|
if (properties.Count > 0)
|
|
{
|
|
EmitTypeAccessorClass(
|
|
writer,
|
|
accessorKind: "Property",
|
|
accessorIface: "global::Opc.Ua.IPropertyAccessor",
|
|
className: typeStem + "StateProperties",
|
|
parentClr: parentClr,
|
|
children: properties);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void EmitTypeAccessorClass(
|
|
ITemplateWriter writer,
|
|
string accessorKind,
|
|
string accessorIface,
|
|
string className,
|
|
string parentClr,
|
|
List<InstanceDesign> children)
|
|
{
|
|
writer.WriteLine();
|
|
writer.WriteLine(
|
|
"/// <summary>Generator-emitted {0} accessor extensions for <c>{1}</c>.</summary>",
|
|
accessorKind, parentClr);
|
|
writer.WriteLine(
|
|
"[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{0}\", \"{1}\")]",
|
|
ToolName, ToolVersion);
|
|
writer.WriteLine(
|
|
"[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute()]");
|
|
writer.WriteLine("public static partial class {0}", className);
|
|
writer.WriteLine("{");
|
|
const string memberIndent = Indent;
|
|
foreach (InstanceDesign child in children
|
|
.OrderBy(c => c.SymbolicName?.Name, StringComparer.Ordinal))
|
|
{
|
|
EmitTypeAccessorMethod(
|
|
writer,
|
|
memberIndent,
|
|
accessorIface,
|
|
parentClr,
|
|
child);
|
|
}
|
|
writer.WriteLine("}");
|
|
}
|
|
|
|
private void EmitTypeAccessorMethod(
|
|
ITemplateWriter writer,
|
|
string indent,
|
|
string accessorIface,
|
|
string parentClr,
|
|
InstanceDesign child)
|
|
{
|
|
string browseName = GetBrowseName(child);
|
|
string accessorName = GetAccessorName(child);
|
|
if (child is PropertyDesign property)
|
|
{
|
|
string valueType = GetVariableValueClrType(property);
|
|
writer.WriteLine();
|
|
writer.WriteLine(
|
|
"{0}/// <summary>Walks the <c>{1}</c> HasProperty child.</summary>",
|
|
indent, EscapeXmlDoc(browseName));
|
|
writer.WriteLine(
|
|
"{0}public static global::Opc.Ua.Server.Fluent.IVariableBuilder<{1}> {2}(",
|
|
indent, valueType, accessorName);
|
|
writer.WriteLine(
|
|
"{0} this {1}<{2}> accessor)",
|
|
indent, accessorIface, parentClr);
|
|
writer.WriteLine(
|
|
"{0} => ((global::Opc.Ua.Server.Fluent.INodeBuilder<{1}>)accessor.Builder).Variable<{2}>(",
|
|
indent, parentClr, valueType);
|
|
writer.WriteLine(
|
|
"{0} new global::Opc.Ua.QualifiedName(\"{1}\"));",
|
|
indent, EscapeStringLiteral(browseName));
|
|
return;
|
|
}
|
|
if (child is VariableDesign variable)
|
|
{
|
|
string valueType = GetVariableValueClrType(variable);
|
|
writer.WriteLine();
|
|
writer.WriteLine(
|
|
"{0}/// <summary>Walks the <c>{1}</c> HasComponent variable child.</summary>",
|
|
indent, EscapeXmlDoc(browseName));
|
|
writer.WriteLine(
|
|
"{0}public static global::Opc.Ua.Server.Fluent.IVariableBuilder<{1}> {2}(",
|
|
indent, valueType, accessorName);
|
|
writer.WriteLine(
|
|
"{0} this {1}<{2}> accessor)",
|
|
indent, accessorIface, parentClr);
|
|
writer.WriteLine(
|
|
"{0} => ((global::Opc.Ua.Server.Fluent.INodeBuilder<{1}>)accessor.Builder).Variable<{2}>(",
|
|
indent, parentClr, valueType);
|
|
writer.WriteLine(
|
|
"{0} new global::Opc.Ua.QualifiedName(\"{1}\"));",
|
|
indent, EscapeStringLiteral(browseName));
|
|
return;
|
|
}
|
|
// ObjectDesign / MethodDesign — both are HasComponent.
|
|
string childStateClr = ResolveChildStateClr(child);
|
|
writer.WriteLine();
|
|
writer.WriteLine(
|
|
"{0}/// <summary>Walks the <c>{1}</c> HasComponent child.</summary>",
|
|
indent, EscapeXmlDoc(browseName));
|
|
writer.WriteLine(
|
|
"{0}public static global::Opc.Ua.Server.Fluent.INodeBuilder<{1}> {2}(",
|
|
indent, childStateClr, accessorName);
|
|
writer.WriteLine(
|
|
"{0} this {1}<{2}> accessor)",
|
|
indent, accessorIface, parentClr);
|
|
writer.WriteLine(
|
|
"{0} => ((global::Opc.Ua.Server.Fluent.INodeBuilder<{1}>)accessor.Builder).Child<{2}>(",
|
|
indent, parentClr, childStateClr);
|
|
writer.WriteLine(
|
|
"{0} new global::Opc.Ua.QualifiedName(\"{1}\"));",
|
|
indent, EscapeStringLiteral(browseName));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the C# state class for an ObjectType design. Uses
|
|
/// the model's namespace prefix table to find the C# namespace
|
|
/// the type lives in; strips the conventional <c>Type</c>
|
|
/// suffix and appends <c>State</c>.
|
|
/// </summary>
|
|
private string ResolveObjectTypeStateClr(ObjectTypeDesign type)
|
|
{
|
|
string typeName = type?.SymbolicName?.Name;
|
|
if (string.IsNullOrEmpty(typeName))
|
|
{
|
|
return null;
|
|
}
|
|
if (!string.IsNullOrEmpty(type.ClassName))
|
|
{
|
|
return type.GetClassName(m_context.ModelDesign.Namespaces) + "State";
|
|
}
|
|
string className = typeName.EndsWith("Type", StringComparison.Ordinal)
|
|
? typeName[..^"Type".Length]
|
|
: typeName;
|
|
string stateName = className + "State";
|
|
string nsUri = type.SymbolicName?.Namespace;
|
|
string prefix = ResolveCSharpNamespaceForUri(nsUri);
|
|
return string.IsNullOrEmpty(prefix)
|
|
? "global::Opc.Ua." + stateName
|
|
: "global::" + prefix + "." + stateName;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the C# state class for a HasComponent child by
|
|
/// looking at its TypeDefinition (the spec-declared type, not
|
|
/// the runtime state class). Falls back to the lowest-common-
|
|
/// denominator state when no TypeDefinition is declared.
|
|
/// </summary>
|
|
private string ResolveChildStateClr(InstanceDesign child)
|
|
{
|
|
if (child is MethodDesign)
|
|
{
|
|
return "global::Opc.Ua.MethodState";
|
|
}
|
|
System.Xml.XmlQualifiedName typeDef = child?.TypeDefinition;
|
|
if (typeDef == null || string.IsNullOrEmpty(typeDef.Name))
|
|
{
|
|
return ResolveStateClrType(child);
|
|
}
|
|
if (child.TypeDefinitionNode is ObjectTypeDesign objectType)
|
|
{
|
|
return ResolveObjectTypeStateClr(objectType);
|
|
}
|
|
string stateName = typeDef.Name.EndsWith("Type", StringComparison.Ordinal)
|
|
? typeDef.Name[..^"Type".Length] + "State"
|
|
: typeDef.Name + "State";
|
|
string prefix = ResolveCSharpNamespaceForUri(typeDef.Namespace);
|
|
return string.IsNullOrEmpty(prefix)
|
|
? "global::Opc.Ua." + stateName
|
|
: "global::" + prefix + "." + stateName;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps a model namespace URI (e.g. <c>http://opcfoundation.org/UA/Pumps/</c>)
|
|
/// to the C# namespace prefix declared in the model's
|
|
/// Namespaces table (e.g. <c>Opc.Ua.Pumps</c>). Returns an
|
|
/// empty string when the URI is the standard OPC UA namespace
|
|
/// (whose C# prefix is the empty <c>Opc.Ua</c> root) or when
|
|
/// no mapping is found.
|
|
/// </summary>
|
|
private string ResolveCSharpNamespaceForUri(string nsUri)
|
|
{
|
|
if (string.IsNullOrEmpty(nsUri))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
Namespace[] namespaces = m_context.ModelDesign.Namespaces;
|
|
if (namespaces != null)
|
|
{
|
|
for (int i = 0; i < namespaces.Length; i++)
|
|
{
|
|
Namespace ns = namespaces[i];
|
|
if (ns != null &&
|
|
string.Equals(ns.Value, nsUri,
|
|
StringComparison.Ordinal))
|
|
{
|
|
return ns.Prefix ?? string.Empty;
|
|
}
|
|
}
|
|
}
|
|
return string.Empty;
|
|
}
|
|
|
|
private void EmitChildAccessor(
|
|
ITemplateWriter writer,
|
|
ChildAccessor child,
|
|
string indent)
|
|
{
|
|
string bodyIndent = indent + Indent;
|
|
string innerIndent = bodyIndent + Indent;
|
|
|
|
writer.WriteLine();
|
|
switch (child.Kind)
|
|
{
|
|
case ChildKind.Variable:
|
|
writer.WriteLine("{0}/// <summary>Typed accessor for variable child <c>{1}</c>.</summary>",
|
|
indent, EscapeXmlDoc(child.BrowseName));
|
|
writer.WriteLine("{0}public global::Opc.Ua.Server.Fluent.IVariableBuilder<{1}> {2}",
|
|
indent, child.ValueClrType, child.AccessorName);
|
|
writer.WriteLine("{0}{{", indent);
|
|
writer.WriteLine("{0}get", bodyIndent);
|
|
writer.WriteLine("{0}{{", bodyIndent);
|
|
writer.WriteLine("{0}ushort __ns = __node.Builder.Context.NamespaceUris.GetIndexOrAppend(\"{1}\");",
|
|
innerIndent, EscapeStringLiteral(child.BrowseNamespaceUri));
|
|
writer.WriteLine("{0}return __node.Variable<{1}>(new global::Opc.Ua.QualifiedName(\"{2}\", __ns));",
|
|
innerIndent,
|
|
child.ValueClrType,
|
|
EscapeStringLiteral(child.BrowseName));
|
|
writer.WriteLine("{0}}}", bodyIndent);
|
|
writer.WriteLine("{0}}}", indent);
|
|
break;
|
|
case ChildKind.Method:
|
|
writer.WriteLine("{0}/// <summary>Typed accessor for method child <c>{1}</c>.</summary>",
|
|
indent, EscapeXmlDoc(child.BrowseName));
|
|
writer.WriteLine("{0}public {1} {2}", indent, child.WrapperClassName, child.AccessorName);
|
|
writer.WriteLine("{0}{{", indent);
|
|
writer.WriteLine("{0}get", bodyIndent);
|
|
writer.WriteLine("{0}{{", bodyIndent);
|
|
writer.WriteLine("{0}ushort __ns = __node.Builder.Context.NamespaceUris.GetIndexOrAppend(\"{1}\");",
|
|
innerIndent, EscapeStringLiteral(child.BrowseNamespaceUri));
|
|
writer.WriteLine("{0}return new {1}(__node.Child<global::Opc.Ua.MethodState>(new global::Opc.Ua.QualifiedName(\"{2}\", __ns)));",
|
|
innerIndent,
|
|
child.WrapperClassName,
|
|
EscapeStringLiteral(child.BrowseName));
|
|
writer.WriteLine("{0}}}", bodyIndent);
|
|
writer.WriteLine("{0}}}", indent);
|
|
break;
|
|
case ChildKind.Object:
|
|
writer.WriteLine("{0}/// <summary>Typed accessor for object child <c>{1}</c>.</summary>",
|
|
indent, EscapeXmlDoc(child.BrowseName));
|
|
writer.WriteLine("{0}public {1} {2}", indent, child.WrapperClassName, child.AccessorName);
|
|
writer.WriteLine("{0}{{", indent);
|
|
writer.WriteLine("{0}get", bodyIndent);
|
|
writer.WriteLine("{0}{{", bodyIndent);
|
|
writer.WriteLine("{0}ushort __ns = __node.Builder.Context.NamespaceUris.GetIndexOrAppend(\"{1}\");",
|
|
innerIndent, EscapeStringLiteral(child.BrowseNamespaceUri));
|
|
writer.WriteLine("{0}return new {1}(__node.Child<{2}>(new global::Opc.Ua.QualifiedName(\"{3}\", __ns)));",
|
|
innerIndent,
|
|
child.WrapperClassName,
|
|
child.ChildStateType,
|
|
EscapeStringLiteral(child.BrowseName));
|
|
writer.WriteLine("{0}}}", bodyIndent);
|
|
writer.WriteLine("{0}}}", indent);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits the typed <c>Publish<TEvent></c> overloads on a
|
|
/// notifier-capable wrapper. Both overloads forward to
|
|
/// <c>Opc.Ua.Server.Fluent.EventNotifierBuilderExtensions</c>;
|
|
/// the wrapper's underlying node-state type is bound as the
|
|
/// <c>TNotifier</c> type argument so callers don't need to spell
|
|
/// it out. The shape mirrors the extension's two overloads (direct
|
|
/// stream + factory).
|
|
/// </summary>
|
|
private static void EmitPublishOverloads(
|
|
ITemplateWriter writer,
|
|
InstanceWrapper wrapper,
|
|
string indent)
|
|
{
|
|
writer.WriteLine();
|
|
writer.WriteLine(
|
|
"{0}/// <summary>Registers an event source for this notifier; lazy by default. See <see cref=\"global::Opc.Ua.Server.Fluent.EventPublishOptions\"/> for activation tuning.</summary>",
|
|
indent);
|
|
writer.WriteLine(
|
|
"{0}public global::Opc.Ua.Server.Fluent.INodeBuilder<{1}> Publish<TEvent>(",
|
|
indent, wrapper.NodeStateType);
|
|
writer.WriteLine(
|
|
"{0}global::System.Collections.Generic.IAsyncEnumerable<TEvent> source,",
|
|
indent + Indent);
|
|
writer.WriteLine(
|
|
"{0}global::Opc.Ua.Server.Fluent.EventPublishOptions? options = null)",
|
|
indent + Indent);
|
|
writer.WriteLine(
|
|
"{0}where TEvent : global::Opc.Ua.BaseEventState",
|
|
indent + Indent);
|
|
writer.WriteLine(
|
|
"{0}=> global::Opc.Ua.Server.Fluent.EventNotifierBuilderExtensions.Publish<{1}, TEvent>(__node, source, options);",
|
|
indent + Indent, wrapper.NodeStateType);
|
|
|
|
writer.WriteLine();
|
|
writer.WriteLine(
|
|
"{0}/// <summary>Registers a factory-based event source for this notifier; the factory runs on each activation. See <see cref=\"global::Opc.Ua.Server.Fluent.EventPublishOptions\"/> for activation tuning.</summary>",
|
|
indent);
|
|
writer.WriteLine(
|
|
"{0}public global::Opc.Ua.Server.Fluent.INodeBuilder<{1}> Publish<TEvent>(",
|
|
indent, wrapper.NodeStateType);
|
|
writer.WriteLine(
|
|
"{0}global::System.Func<{1}, global::Opc.Ua.ISystemContext, global::System.Threading.CancellationToken, global::System.Collections.Generic.IAsyncEnumerable<TEvent>> factory,",
|
|
indent + Indent, wrapper.NodeStateType);
|
|
writer.WriteLine(
|
|
"{0}global::Opc.Ua.Server.Fluent.EventPublishOptions? options = null)",
|
|
indent + Indent);
|
|
writer.WriteLine(
|
|
"{0}where TEvent : global::Opc.Ua.BaseEventState",
|
|
indent + Indent);
|
|
writer.WriteLine(
|
|
"{0}=> global::Opc.Ua.Server.Fluent.EventNotifierBuilderExtensions.Publish<{1}, TEvent>(__node, factory, options);",
|
|
indent + Indent, wrapper.NodeStateType);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits one wrapper class for a method instance with typed
|
|
/// <c>OnCall</c> overloads at the supplied <paramref name="indent"/>.
|
|
/// </summary>
|
|
private void EmitMethodWrapper(
|
|
ITemplateWriter writer,
|
|
MethodWrapper method,
|
|
string indent)
|
|
{
|
|
string memberIndent = indent + Indent;
|
|
|
|
writer.WriteLine();
|
|
writer.WriteLine("{0}/// <summary>Typed method-call wrapper for the predefined method.</summary>", indent);
|
|
writer.WriteLine(
|
|
"{0}[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{1}\", \"{2}\")]",
|
|
indent,
|
|
ToolName,
|
|
ToolVersion);
|
|
writer.WriteLine("{0}internal sealed class {1}", indent, method.ClassName);
|
|
writer.WriteLine("{0}{{", indent);
|
|
writer.WriteLine("{0}private readonly global::Opc.Ua.Server.Fluent.INodeBuilder<global::Opc.Ua.MethodState> __node;",
|
|
memberIndent);
|
|
writer.WriteLine();
|
|
writer.WriteLine("{0}internal {1}(global::Opc.Ua.Server.Fluent.INodeBuilder<global::Opc.Ua.MethodState> node)",
|
|
memberIndent, method.ClassName);
|
|
writer.WriteLine("{0}{{", memberIndent);
|
|
writer.WriteLine("{0}__node = node ?? throw new global::System.ArgumentNullException(nameof(node));",
|
|
memberIndent + Indent);
|
|
writer.WriteLine("{0}}}", memberIndent);
|
|
writer.WriteLine();
|
|
writer.WriteLine("{0}/// <summary>Underlying typed node builder. Use to drop into the non-typed fluent surface.</summary>",
|
|
memberIndent);
|
|
writer.WriteLine("{0}public global::Opc.Ua.Server.Fluent.INodeBuilder<global::Opc.Ua.MethodState> Builder => __node;",
|
|
memberIndent);
|
|
writer.WriteLine();
|
|
writer.WriteLine("{0}/// <summary>Resolved underlying method state.</summary>", memberIndent);
|
|
writer.WriteLine("{0}public global::Opc.Ua.MethodState Node => __node.Node;", memberIndent);
|
|
|
|
// Sync typed OnCall.
|
|
EmitMethodOnCall(writer, method, async: false, indent: memberIndent);
|
|
// Async typed OnCall.
|
|
EmitMethodOnCall(writer, method, async: true, indent: memberIndent);
|
|
|
|
writer.WriteLine("{0}}}", indent);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits one OnCall overload for a method wrapper. The overload
|
|
/// shape is determined by the method's argument signature plus the
|
|
/// requested sync/async flavor. <paramref name="indent"/> is the
|
|
/// member indent of the enclosing method wrapper.
|
|
/// </summary>
|
|
private void EmitMethodOnCall(
|
|
ITemplateWriter writer,
|
|
MethodWrapper method,
|
|
bool async,
|
|
string indent)
|
|
{
|
|
string bodyIndent = indent + Indent;
|
|
string lambdaIndent = bodyIndent + Indent;
|
|
|
|
string targetNamespace = m_context.ModelDesign.TargetNamespace.Value;
|
|
Namespace[] namespaces = m_context.ModelDesign.Namespaces;
|
|
Parameter[] inputs = method.Inputs;
|
|
Parameter[] outputs = method.Outputs;
|
|
|
|
string returnTypeAnnotation = GetReturnTypeAnnotation(outputs, targetNamespace, namespaces);
|
|
string handlerType;
|
|
if (async)
|
|
{
|
|
if (inputs.Length == 0 && outputs.Length == 0)
|
|
{
|
|
handlerType = "global::System.Func<global::System.Threading.CancellationToken, global::System.Threading.Tasks.ValueTask>";
|
|
}
|
|
else if (outputs.Length == 0)
|
|
{
|
|
handlerType = CoreUtils.Format(
|
|
"global::System.Func<{0}, global::System.Threading.CancellationToken, global::System.Threading.Tasks.ValueTask>",
|
|
FormatInputTypeList(inputs, targetNamespace, namespaces));
|
|
}
|
|
else
|
|
{
|
|
handlerType = CoreUtils.Format(
|
|
"global::System.Func<{0}{1}global::System.Threading.CancellationToken, global::System.Threading.Tasks.ValueTask{2}>",
|
|
FormatInputTypeList(inputs, targetNamespace, namespaces),
|
|
inputs.Length == 0 ? string.Empty : ", ",
|
|
returnTypeAnnotation);
|
|
}
|
|
}
|
|
else if (inputs.Length == 0 && outputs.Length == 0)
|
|
{
|
|
handlerType = "global::System.Action";
|
|
}
|
|
else if (outputs.Length == 0)
|
|
{
|
|
handlerType = CoreUtils.Format(
|
|
"global::System.Action<{0}>",
|
|
FormatInputTypeList(inputs, targetNamespace, namespaces));
|
|
}
|
|
else
|
|
{
|
|
handlerType = CoreUtils.Format(
|
|
"global::System.Func<{0}{1}{2}>",
|
|
FormatInputTypeList(inputs, targetNamespace, namespaces),
|
|
inputs.Length == 0 ? string.Empty : ", ",
|
|
StripAngleBrackets(returnTypeAnnotation, defaultIfEmpty: "void"));
|
|
}
|
|
|
|
writer.WriteLine();
|
|
writer.WriteLine("{0}/// <summary>Wires the method-call handler ({1}).</summary>",
|
|
indent, async ? "async" : "sync");
|
|
writer.WriteLine("{0}public {1} OnCall({2} handler)", indent, method.ClassName, handlerType);
|
|
writer.WriteLine("{0}{{", indent);
|
|
writer.WriteLine("{0}if (handler == null) throw new global::System.ArgumentNullException(nameof(handler));",
|
|
bodyIndent);
|
|
if (async)
|
|
{
|
|
writer.WriteLine("{0}__node.OnCall(async (", bodyIndent);
|
|
writer.WriteLine("{0}global::Opc.Ua.ISystemContext __ctx,", lambdaIndent);
|
|
writer.WriteLine("{0}global::Opc.Ua.MethodState __m,", lambdaIndent);
|
|
writer.WriteLine("{0}global::Opc.Ua.NodeId __oid,", lambdaIndent);
|
|
writer.WriteLine("{0}global::Opc.Ua.ArrayOf<global::Opc.Ua.Variant> __inputs,", lambdaIndent);
|
|
writer.WriteLine("{0}global::System.Collections.Generic.List<global::Opc.Ua.Variant> __outputs,", lambdaIndent);
|
|
writer.WriteLine("{0}global::System.Threading.CancellationToken __ct) =>", lambdaIndent);
|
|
writer.WriteLine("{0}{{", bodyIndent);
|
|
}
|
|
else
|
|
{
|
|
writer.WriteLine("{0}__node.OnCall((", bodyIndent);
|
|
writer.WriteLine("{0}global::Opc.Ua.ISystemContext __ctx,", lambdaIndent);
|
|
writer.WriteLine("{0}global::Opc.Ua.MethodState __m,", lambdaIndent);
|
|
writer.WriteLine("{0}global::Opc.Ua.NodeId __oid,", lambdaIndent);
|
|
writer.WriteLine("{0}global::Opc.Ua.ArrayOf<global::Opc.Ua.Variant> __inputs,", lambdaIndent);
|
|
writer.WriteLine("{0}global::System.Collections.Generic.List<global::Opc.Ua.Variant> __outputs) =>", lambdaIndent);
|
|
writer.WriteLine("{0}{{", bodyIndent);
|
|
}
|
|
|
|
// Validate input arg count.
|
|
if (inputs.Length > 0)
|
|
{
|
|
writer.WriteLine("{0}if (__inputs.Count < {1})", lambdaIndent, inputs.Length);
|
|
writer.WriteLine("{0}{{", lambdaIndent);
|
|
writer.WriteLine("{0}return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadArgumentsMissing);",
|
|
lambdaIndent + Indent);
|
|
writer.WriteLine("{0}}}", lambdaIndent);
|
|
}
|
|
|
|
// Unpack inputs.
|
|
for (int ii = 0; ii < inputs.Length; ii++)
|
|
{
|
|
EmitInputUnpack(writer, inputs[ii], ii, targetNamespace, namespaces, lambdaIndent);
|
|
}
|
|
|
|
// Invoke user handler.
|
|
if (async)
|
|
{
|
|
if (outputs.Length == 0)
|
|
{
|
|
writer.Write("{0}await handler(", lambdaIndent);
|
|
EmitInputArgPassThrough(writer, inputs, withCt: true);
|
|
writer.WriteLine(").ConfigureAwait(false);");
|
|
}
|
|
else
|
|
{
|
|
writer.Write("{0}var __r = await handler(", lambdaIndent);
|
|
EmitInputArgPassThrough(writer, inputs, withCt: true);
|
|
writer.WriteLine(").ConfigureAwait(false);");
|
|
}
|
|
}
|
|
else if (outputs.Length == 0)
|
|
{
|
|
writer.Write("{0}handler(", lambdaIndent);
|
|
EmitInputArgPassThrough(writer, inputs, withCt: false);
|
|
writer.WriteLine(");");
|
|
}
|
|
else
|
|
{
|
|
writer.Write("{0}var __r = handler(", lambdaIndent);
|
|
EmitInputArgPassThrough(writer, inputs, withCt: false);
|
|
writer.WriteLine(");");
|
|
}
|
|
|
|
// Marshal outputs.
|
|
for (int ii = 0; ii < outputs.Length; ii++)
|
|
{
|
|
EmitOutputBox(writer, outputs[ii], ii, outputs.Length, lambdaIndent);
|
|
}
|
|
|
|
writer.WriteLine("{0}return global::Opc.Ua.ServiceResult.Good;", lambdaIndent);
|
|
writer.WriteLine("{0}}});", bodyIndent);
|
|
writer.WriteLine("{0}return this;", bodyIndent);
|
|
writer.WriteLine("{0}}}", indent);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits the typed unpack code for a single input argument. Mirrors
|
|
/// the logic in <c>ObjectTypeProxyGenerator</c>. <paramref name="indent"/>
|
|
/// is the lambda-body indent of the surrounding OnCall.
|
|
/// </summary>
|
|
private static void EmitInputUnpack(
|
|
ITemplateWriter writer,
|
|
Parameter input,
|
|
int index,
|
|
string targetNamespace,
|
|
Namespace[] namespaces,
|
|
string indent)
|
|
{
|
|
string innerIndent = indent + Indent;
|
|
string typeName = input.DataTypeNode.GetMethodArgumentTypeAsCode(
|
|
input.ValueRank,
|
|
targetNamespace,
|
|
namespaces,
|
|
input.IsOptional);
|
|
string local = "__a" + index;
|
|
switch (input.DataTypeNode.BasicDataType)
|
|
{
|
|
case BasicDataType.UserDefined:
|
|
writer.WriteLine("{0}if (!__inputs[{1}].TryGetStructure(out {2} {3}))",
|
|
indent, index, typeName, local);
|
|
writer.WriteLine("{0}{{", indent);
|
|
writer.WriteLine("{0}return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadInvalidArgument);",
|
|
innerIndent);
|
|
writer.WriteLine("{0}}}", indent);
|
|
break;
|
|
case BasicDataType.BaseDataType when input.ValueRank == ValueRank.Scalar:
|
|
writer.WriteLine("{0}{1} {2} = __inputs[{3}];", indent, typeName, local, index);
|
|
break;
|
|
default:
|
|
writer.WriteLine("{0}if (!__inputs[{1}].TryGetValue(out {2} {3}))",
|
|
indent, index, typeName, local);
|
|
writer.WriteLine("{0}{{", indent);
|
|
writer.WriteLine("{0}return new global::Opc.Ua.ServiceResult(global::Opc.Ua.StatusCodes.BadInvalidArgument);",
|
|
innerIndent);
|
|
writer.WriteLine("{0}}}", indent);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits the comma-separated list of unpacked input locals (plus
|
|
/// optional CancellationToken) for the user-handler invocation.
|
|
/// </summary>
|
|
private static void EmitInputArgPassThrough(
|
|
ITemplateWriter writer,
|
|
Parameter[] inputs,
|
|
bool withCt)
|
|
{
|
|
for (int ii = 0; ii < inputs.Length; ii++)
|
|
{
|
|
if (ii > 0)
|
|
{
|
|
writer.Write(", ");
|
|
}
|
|
writer.Write("__a" + ii);
|
|
}
|
|
if (withCt)
|
|
{
|
|
if (inputs.Length > 0)
|
|
{
|
|
writer.Write(", ");
|
|
}
|
|
writer.Write("__ct");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits the boxing code for a single output argument. For multi-
|
|
/// output methods the user returns a <c>ValueTuple</c> and we
|
|
/// destructure by field name (<c>Item1</c>, <c>Item2</c>, …).
|
|
/// <paramref name="indent"/> is the lambda-body indent of the
|
|
/// surrounding OnCall.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The base <see cref="MethodState"/> dispatcher
|
|
/// pre-populates the outputs list with one default-valued
|
|
/// <see cref="Variant"/> per declared output argument
|
|
/// before invoking the user handler, so the wrapper assigns
|
|
/// boxed values by index rather than appending to avoid
|
|
/// double-counting outputs at the wire.
|
|
/// </remarks>
|
|
private static void EmitOutputBox(
|
|
ITemplateWriter writer,
|
|
Parameter output,
|
|
int index,
|
|
int totalOutputs,
|
|
string indent)
|
|
{
|
|
string source;
|
|
if (totalOutputs == 1)
|
|
{
|
|
source = "__r";
|
|
}
|
|
else
|
|
{
|
|
source = "__r.Item" + (index + 1).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
string indexLiteral = index.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
|
|
switch (output.DataTypeNode.BasicDataType)
|
|
{
|
|
case BasicDataType.UserDefined:
|
|
writer.WriteLine("{0}__outputs[{1}] = global::Opc.Ua.Variant.FromStructure({2});", indent, indexLiteral, source);
|
|
break;
|
|
case BasicDataType.BaseDataType when output.ValueRank == ValueRank.Scalar:
|
|
writer.WriteLine("{0}__outputs[{1}] = {2};", indent, indexLiteral, source);
|
|
break;
|
|
default:
|
|
writer.WriteLine("{0}__outputs[{1}] = global::Opc.Ua.Variant.From({2});", indent, indexLiteral, source);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the <c>ValueTask<…></c> suffix used in the method's
|
|
/// async return type. Mirrors
|
|
/// <see cref="ObjectTypeProxyGenerator"/> verbatim.
|
|
/// </summary>
|
|
private static string GetReturnTypeAnnotation(
|
|
Parameter[] outputs,
|
|
string targetNamespace,
|
|
Namespace[] namespaces)
|
|
{
|
|
if (outputs.Length == 0)
|
|
{
|
|
return string.Empty;
|
|
}
|
|
if (outputs.Length == 1)
|
|
{
|
|
return CoreUtils.Format(
|
|
"<{0}>",
|
|
outputs[0].DataTypeNode.GetMethodArgumentTypeAsCode(
|
|
outputs[0].ValueRank,
|
|
targetNamespace,
|
|
namespaces,
|
|
outputs[0].IsOptional));
|
|
}
|
|
var sb = new System.Text.StringBuilder();
|
|
sb.Append("<(");
|
|
for (int ii = 0; ii < outputs.Length; ii++)
|
|
{
|
|
if (ii > 0)
|
|
{
|
|
sb.Append(", ");
|
|
}
|
|
sb.Append(outputs[ii].DataTypeNode.GetMethodArgumentTypeAsCode(
|
|
outputs[ii].ValueRank,
|
|
targetNamespace,
|
|
namespaces,
|
|
outputs[ii].IsOptional))
|
|
.Append(" Item" + (ii + 1).ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
}
|
|
sb.Append(")>");
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string StripAngleBrackets(string returnTypeAnnotation, string defaultIfEmpty)
|
|
{
|
|
if (string.IsNullOrEmpty(returnTypeAnnotation))
|
|
{
|
|
return defaultIfEmpty;
|
|
}
|
|
// Strip leading '<' and trailing '>'.
|
|
return returnTypeAnnotation[1..^1];
|
|
}
|
|
|
|
private static string FormatInputTypeList(
|
|
Parameter[] inputs,
|
|
string targetNamespace,
|
|
Namespace[] namespaces)
|
|
{
|
|
var sb = new System.Text.StringBuilder();
|
|
for (int ii = 0; ii < inputs.Length; ii++)
|
|
{
|
|
if (ii > 0)
|
|
{
|
|
sb.Append(", ");
|
|
}
|
|
sb.Append(inputs[ii].DataTypeNode.GetMethodArgumentTypeAsCode(
|
|
inputs[ii].ValueRank,
|
|
targetNamespace,
|
|
namespaces,
|
|
inputs[ii].IsOptional));
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
// Helpers
|
|
/// <summary>
|
|
/// Returns the wrapper-key string used to deduplicate wrappers and
|
|
/// to compose CLR class names. Combines the root's symbolic id
|
|
/// with the relative path from the root.
|
|
/// </summary>
|
|
private static string ComposeKey(InstanceDesign root, string relativePath)
|
|
{
|
|
string rootId = root?.SymbolicId?.Name ?? string.Empty;
|
|
if (string.IsNullOrEmpty(relativePath))
|
|
{
|
|
return rootId;
|
|
}
|
|
return rootId + "_" + relativePath;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the simple leaf name (the last segment of the relative
|
|
/// path) used as the C# class name's stem. Honors the convention
|
|
/// that segment names themselves can contain underscores so we
|
|
/// rely on the instance's <c>SymbolicName.Name</c> rather than
|
|
/// splitting on <c>NodeDesign.PathChar</c>. For the root
|
|
/// instance (empty <paramref name="relativePath"/>) the leaf is
|
|
/// the root's own <c>SymbolicId.Name</c>.
|
|
/// </summary>
|
|
private static string ResolveLeafName(
|
|
InstanceDesign root,
|
|
string relativePath,
|
|
NodeDesign instance)
|
|
{
|
|
if (string.IsNullOrEmpty(relativePath))
|
|
{
|
|
return root?.SymbolicId?.Name ?? string.Empty;
|
|
}
|
|
string symbolicName = instance?.SymbolicName?.Name;
|
|
if (string.IsNullOrEmpty(symbolicName))
|
|
{
|
|
return relativePath;
|
|
}
|
|
return symbolicName;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the wrapper key of the lexical parent for the wrapper
|
|
/// at <paramref name="relativePath"/> under <paramref name="root"/>.
|
|
/// Returns <c>null</c> for the root itself (lives at namespace
|
|
/// scope), the root's key for direct children, and the parent
|
|
/// path's key for deeper nesting.
|
|
/// </summary>
|
|
private static string ResolveParentKey(
|
|
InstanceDesign root,
|
|
string relativePath,
|
|
string leafName)
|
|
{
|
|
if (string.IsNullOrEmpty(relativePath))
|
|
{
|
|
return null;
|
|
}
|
|
if (relativePath.Length == leafName.Length)
|
|
{
|
|
return ComposeKey(root, string.Empty);
|
|
}
|
|
int trim = leafName.Length + 1;
|
|
if (relativePath.Length <= trim)
|
|
{
|
|
return ComposeKey(root, string.Empty);
|
|
}
|
|
return ComposeKey(root, relativePath[..^trim]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the wrapper's CLR class name. Wrappers are emitted as
|
|
/// nested types so the simple leaf name is sufficient — full
|
|
/// dotted access is composed by the consumer through the chain
|
|
/// of typed accessor properties.
|
|
/// </summary>
|
|
private static string ComposeWrapperClassName(string leafName, string suffix)
|
|
{
|
|
return (leafName ?? string.Empty) + suffix;
|
|
}
|
|
|
|
private static string GetAccessorName(NodeDesign node)
|
|
{
|
|
string name = node?.SymbolicName?.Name;
|
|
if (string.IsNullOrEmpty(name))
|
|
{
|
|
return "Item";
|
|
}
|
|
return name.ToSafeSymbolName(toLowerCamelCase: false);
|
|
}
|
|
|
|
private static string GetBrowseName(NodeDesign node)
|
|
{
|
|
// Per the design schema the BrowseName is identical to the
|
|
// SymbolicName when it isn't explicitly overridden. Use the
|
|
// BrowseName when present so source-generated identifiers and
|
|
// the on-the-wire OPC UA browse name stay aligned.
|
|
if (!string.IsNullOrEmpty(node?.BrowseName))
|
|
{
|
|
return node.BrowseName;
|
|
}
|
|
return node?.SymbolicName?.Name ?? string.Empty;
|
|
}
|
|
|
|
private string ResolveNodeBrowseNamespace(NodeDesign node)
|
|
{
|
|
string ns = node?.SymbolicName?.Namespace;
|
|
if (!string.IsNullOrEmpty(ns))
|
|
{
|
|
return ns;
|
|
}
|
|
return m_context.ModelDesign.TargetNamespace?.Value ?? string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true when the supplied node should expose typed
|
|
/// <c>Publish<TEvent></c> overloads on its wrapper. A node
|
|
/// qualifies if it carries the <c>EventNotifier=SubscribeToEvents</c>
|
|
/// attribute (modeled as <see cref="ObjectDesign.SupportsEvents"/>;
|
|
/// the model validator auto-promotes nodes with forward
|
|
/// <c>HasEventSource</c>/<c>HasNotifier</c> references) or has a
|
|
/// forward <c>GeneratesEvent</c>/<c>AlwaysGeneratesEvent</c>
|
|
/// reference. Per the locked decision the typed overload is
|
|
/// emitted only on spec-accurate notifier candidates so call sites
|
|
/// don't drift from the model intent.
|
|
/// </summary>
|
|
private static bool QualifiesAsEventNotifier(NodeDesign node)
|
|
{
|
|
if (node is ObjectDesign od && od.SupportsEvents)
|
|
{
|
|
return true;
|
|
}
|
|
Reference[] references = node?.References;
|
|
if (references == null || references.Length == 0)
|
|
{
|
|
return false;
|
|
}
|
|
foreach (Reference reference in references)
|
|
{
|
|
if (reference == null || reference.IsInverse)
|
|
{
|
|
continue;
|
|
}
|
|
if (reference.ReferenceType?.Name is "GeneratesEvent" or
|
|
"AlwaysGeneratesEvent")
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the C# type name of the runtime <see cref="NodeState"/>
|
|
/// derivative for the supplied instance.
|
|
/// </summary>
|
|
private string ResolveStateClrType(NodeDesign node)
|
|
{
|
|
// For object instances, use BaseObjectState as the lowest common
|
|
// denominator. The user can call .Builder.As<TConcrete>() to
|
|
// narrow.
|
|
if (node is ObjectDesign)
|
|
{
|
|
return "global::Opc.Ua.BaseObjectState";
|
|
}
|
|
if (node is MethodDesign)
|
|
{
|
|
return "global::Opc.Ua.MethodState";
|
|
}
|
|
if (node is VariableDesign)
|
|
{
|
|
return "global::Opc.Ua.BaseDataVariableState";
|
|
}
|
|
return "global::Opc.Ua.NodeState";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the CLR type name for a variable's value attribute,
|
|
/// inferred from the variable's <c>DataType</c> and <c>ValueRank</c>.
|
|
/// </summary>
|
|
private string GetVariableValueClrType(VariableDesign variable)
|
|
{
|
|
return variable.DataTypeNode.GetMethodArgumentTypeAsCode(
|
|
variable.ValueRank,
|
|
m_context.ModelDesign.TargetNamespace.Value,
|
|
m_context.ModelDesign.Namespaces,
|
|
isOptional: false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits the constructor argument used to materialize the
|
|
/// top-level instance's <see cref="NodeId"/>. Numeric ids preferred
|
|
/// when present; otherwise falls back to the SymbolicId string.
|
|
/// </summary>
|
|
private static string EmitNodeIdConstructorArg(InstanceDesign node)
|
|
{
|
|
if (node.NumericIdSpecified && node.NumericId != 0u)
|
|
{
|
|
return CoreUtils.Format("{0}u",
|
|
node.NumericId.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
}
|
|
if (!string.IsNullOrEmpty(node.StringId))
|
|
{
|
|
return CoreUtils.Format("\"{0}\"", EscapeStringLiteral(node.StringId));
|
|
}
|
|
// No id assigned — fall back to the SymbolicId.Name as a string id.
|
|
return CoreUtils.Format("\"{0}\"",
|
|
EscapeStringLiteral(node.SymbolicId?.Name ?? string.Empty));
|
|
}
|
|
|
|
private static string EscapeStringLiteral(string value)
|
|
{
|
|
if (string.IsNullOrEmpty(value))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
return value
|
|
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
|
.Replace("\"", "\\\"", StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Escapes a value for safe inclusion in a generated XML documentation
|
|
/// comment. OptionalPlaceholder browse names such as <c><Binding></c>
|
|
/// contain angle brackets that would otherwise produce malformed XML
|
|
/// (CS1570) when the consuming project enables documentation-file
|
|
/// generation.
|
|
/// </summary>
|
|
private static string EscapeXmlDoc(string value)
|
|
{
|
|
if (string.IsNullOrEmpty(value))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
return value
|
|
.Replace("&", "&", StringComparison.Ordinal)
|
|
.Replace("<", "<", StringComparison.Ordinal)
|
|
.Replace(">", ">", StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// State
|
|
/// </summary>
|
|
private readonly IGeneratorContext m_context;
|
|
private Dictionary<string, InstanceWrapper> m_wrappers = [];
|
|
private Dictionary<string, MethodWrapper> m_methodWrappers = [];
|
|
|
|
/// <summary>
|
|
/// Single nesting step. Wrappers are emitted inside the body of
|
|
/// the file template at column 4; each additional nesting level
|
|
/// adds one Indent.
|
|
/// </summary>
|
|
private const string Indent = " ";
|
|
|
|
private static string ToolName
|
|
=> System.Reflection.Assembly.GetExecutingAssembly().GetName().Name;
|
|
|
|
private static string ToolVersion
|
|
=> System.Reflection.Assembly.GetExecutingAssembly().GetName().Version.ToString();
|
|
|
|
private enum ChildKind
|
|
{
|
|
Variable,
|
|
Method,
|
|
Object
|
|
}
|
|
|
|
private sealed class InstanceWrapper
|
|
{
|
|
public string Key;
|
|
public string ClassName;
|
|
public string LeafName;
|
|
public string ParentKey;
|
|
public string NodeStateType;
|
|
public string BrowseNamespaceUri;
|
|
public bool SupportsPublish;
|
|
public List<ChildAccessor> Children;
|
|
public List<string> ChildObjectKeys = [];
|
|
public List<string> ChildMethodKeys = [];
|
|
}
|
|
|
|
private sealed class ChildAccessor
|
|
{
|
|
public string AccessorName;
|
|
public string BrowseName;
|
|
public string BrowseNamespaceUri;
|
|
public ChildKind Kind;
|
|
|
|
/// <summary>
|
|
/// CLR type name of the variable's value.
|
|
/// </summary>
|
|
public string ValueClrType;
|
|
|
|
/// <summary>
|
|
/// Generated wrapper class name for a method or object child.
|
|
/// </summary>
|
|
public string WrapperClassName;
|
|
|
|
/// <summary>
|
|
/// Key into <c>m_wrappers</c> for object children.
|
|
/// </summary>
|
|
public string ChildKey;
|
|
|
|
/// <summary>
|
|
/// Node state type for object children.
|
|
/// </summary>
|
|
public string ChildStateType;
|
|
}
|
|
|
|
private sealed class MethodWrapper
|
|
{
|
|
public string Key;
|
|
public string ClassName;
|
|
public string LeafName;
|
|
public string ParentKey;
|
|
public Parameter[] Inputs;
|
|
public Parameter[] Outputs;
|
|
}
|
|
}
|
|
}
|