mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 22:55:08 +08:00
0374e19121
# Description _Describe the changes here to communicate to the maintainers why they should accept this pull request. By default - this will become the Commit message after merging and thus define history._ Add provider-backed MachineTool, bundled WMTP, GMS and CranesHoists companions using the existing Machinery/MachineVision fluent, DI and typed-client approach. **The PR is open/non-draft by user choice. Current head `ecc4a7d6ca3e8004cf28ab54da605da302d026bf` is mergeable and has 108 successful checks, six intentionally skipped checks, zero failures and zero pending checks. All five primary workflows completed successfully at that exact head.** | Family | Released specification/model | Packaging | | --- | --- | --- | | MachineTool | OPC 40501-1, 1.02.0 | Model, Server, Client and MCP; normal root versions | | WMTP | OPC 40505, 1.0.0 | Bundled in MachineTool; distinct CLR/wire namespace and independent WMTP-only hosting | | GMS | OPC 40210 reference 1.0.1, normative model 1.0.0 | Model, Server, Client and MCP; normal root versions | | CranesHoists | OPC 40020-1, 1.00 | Model, Server, Client and MCP; all four plus Debug variants forced preview | There are **12 new packable projects**. No separate WMTP package/profile/guide, root version bump, tag, release or package publication is included. Mandatory instance structure is the default; selected optional features require explicit providers. The implementation reuses Machinery results/jobs/files/ProcessValues and Robotics topology, including retained MachineTool Production, defined GMS jobs/parts, modern ISA95/Machinery jobs, shared-owner hosting and isolated instances alongside MachineVision. Runtime and integration fixes preserve raw quality/timestamps, coherent acquisition, namespace ownership, accepted-configuration state, borrowed-resource lifetime, session/node/file cleanup, terminal-value publication, event propagation and endpoint-aware capability publication. Firmware/licence transfer means provider acceptance, not installation/activation. Cancellation does not silently abort another caller's operation. The later upstream MCP expansion is integrated without losing either family set or its default-off privileged/live execution policy. ### Conformance and scope Canonical model inputs are pinned to UA-Nodeset revision `96a08945425d0295edb3607d2a38f6841ef44046`. **GMS upstream exception:** [Annex A.1](https://reference.opcfoundation.org/specs/OPC-40210/annex-a) makes the released NodeSet normative, but §12.3.2.7 requires undefined `GMS_ProductionStatisticsType`. Inherited statistics and defined jobs/parts are implemented without inventing a standard type/NodeId; the complete GMS Production facet is withheld. The requirement ledger records **102 positively evidenced CU rows, 96 directly negative-tested rows and six explicit negative-evidence limitations**. All 648 test-class/name/TRX links were independently resolved to passing cases. The six limitations concern Stacklight, retained legacy PowerOnDuration, Machinery PowerOnDuration, WorkingUnit, Obligation and MaintenanceMode. They are not claims that every invalid-input or hardware-control behavior was tested. The undefined GMS requirement is outside the 102 defined CUs. No external certification claim is made. Retained baseline complete-assembly line coverage, with generated code retained and no source exclusions: | Family | Model | Server | Client | | --- | ---: | ---: | ---: | | MachineTool/WMTP | 83.80% | 91.98% | 84.74% | | GMS | 95.51% | 90.63% | 88.38% | | CranesHoists | 92.76% | 94.45% | 92.24% | These nine measurements predate the later focused review fixes and merges; they are not relabelled as new `ecc4a7d6c` percentages. The current head's separate CI code-coverage check succeeded. No new exclusions or threshold relaxations were introduced. ## Related Issues - Fixes [#4764](https://github.com/OPCFoundation/UA-.NETStandard/issues/4764), the implementation/design and acceptance tracker. ## 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. - [x] 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. - [ ] 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. - [x] I have addressed **all** PR feedback received.
278 lines
13 KiB
C#
278 lines
13 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Opc.Ua;
|
|
using Opc.Ua.GMS.Contracts;
|
|
using Opc.Ua.GMS.Server;
|
|
using Opc.Ua.Machinery.Server;
|
|
using Opc.Ua.MachineTool;
|
|
using Opc.Ua.Server;
|
|
using Opc.Ua.Server.Hosting;
|
|
|
|
namespace GmsSample.Server
|
|
{
|
|
/// <summary>
|
|
/// Owns independent simulated providers and result stores on the one injected Machinery namespace owner.
|
|
/// </summary>
|
|
internal sealed class GmsSimulation : IServerStartupTask, IAsyncDisposable
|
|
{
|
|
/// <summary>
|
|
/// Receives lifetime bounds, the simulation clock and source-generated telemetry through DI.
|
|
/// </summary>
|
|
public GmsSimulation(GmsServerOptions options, TimeProvider clock, ITelemetryContext telemetry)
|
|
{
|
|
m_options = options ?? throw new ArgumentNullException(nameof(options));
|
|
m_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
|
m_logger = (telemetry ?? throw new ArgumentNullException(nameof(telemetry))).CreateLogger<GmsSimulation>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Completes only after real OPC UA address-space initialization.
|
|
/// </summary>
|
|
internal Task Ready => m_ready.Task;
|
|
|
|
/// <inheritdoc/>
|
|
public ValueTask OnServerStartedAsync(IServerContext server, CancellationToken cancellationToken = default)
|
|
{
|
|
if (server == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(server));
|
|
}
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (m_instances.Count != m_options.Instances)
|
|
{
|
|
throw new InvalidOperationException("Not all GMS instances completed configuration.");
|
|
}
|
|
m_ready.TrySetResult(true);
|
|
return default;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drains handles before releasing their borrowed, application-owned providers and stores.
|
|
/// </summary>
|
|
/// <exception cref="AggregateException">One or more instance cleanups failed.</exception>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (m_disposed)
|
|
{
|
|
return;
|
|
}
|
|
m_disposed = true;
|
|
var failures = new List<Exception>();
|
|
foreach (GmsInstance instance in m_instances)
|
|
{
|
|
try
|
|
{
|
|
await instance.Handle.DisposeAsync().ConfigureAwait(false);
|
|
ArrayOf<string> results = await instance.Store.ListIdsAsync(256).ConfigureAwait(false);
|
|
Console.WriteLine($"RELEASED {instance.Id} storedResults={results.Count} capacity=256");
|
|
for (int index = 0; index < results.Count; index++)
|
|
{
|
|
await instance.Store.RemoveAsync(results[index]).ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
failures.Add(error);
|
|
}
|
|
}
|
|
int count = m_instances.Count;
|
|
m_instances.Clear();
|
|
if (failures.Count != 0)
|
|
{
|
|
throw new AggregateException("GMS sample cleanup failed.", failures);
|
|
}
|
|
m_logger.InstancesReleased(count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds actual generated roots with explicitly selected capabilities and isolated result ownership.
|
|
/// </summary>
|
|
internal async ValueTask ConfigureAsync(IMachineryBuildContext context, CancellationToken cancellationToken)
|
|
{
|
|
for (int index = 1; index <= m_options.Instances; index++)
|
|
{
|
|
string id = FormattableString.Invariant($"Gms{index}");
|
|
DateTimeUtc acquired = AcquiredAt();
|
|
var provider = new InMemoryGmsProvider(
|
|
GmsSimulationData.Snapshot(id, m_options.Advanced, 0, acquired));
|
|
var store = new InMemoryGmsResultStore(
|
|
context.Context.AsMessageContext(), GmsSimulationData.ResultLimits);
|
|
GmsBuilder builder = context.AddGms(id)
|
|
.WithProvider(provider)
|
|
.WithIdentification(identity =>
|
|
{
|
|
identity.Manufacturer = new LocalizedText("OPC Foundation simulation");
|
|
identity.SerialNumber = id;
|
|
identity.ProductInstanceUri = "urn:opcfoundation.org:gms:simulation:" + id;
|
|
identity.Model = new LocalizedText("SIMULATION ONLY; no physical equipment");
|
|
})
|
|
.WithResults(store, new GmsResultBindingOptions
|
|
{
|
|
Limits = GmsSimulationData.ResultLimits,
|
|
PublishedResults = 2,
|
|
EnableFileTransfer = m_options.Advanced,
|
|
IncludeUsedTools = m_options.Advanced,
|
|
EnableIntermediateEvents = m_options.Advanced,
|
|
Corrections = m_options.Advanced ? [GmsSimulationData.Correction(id, 0, acquired)] : default
|
|
});
|
|
if (m_options.Advanced)
|
|
{
|
|
builder.WithMachineToolFeatures(MachineToolFeatures.ProductionStatistics)
|
|
.WithOptions(new GmsSystemOptions
|
|
{
|
|
Features = GmsFeatures.Identification | GmsFeatures.Sensors | GmsFeatures.Loading |
|
|
GmsFeatures.ToolMonitoring | GmsFeatures.Accessories | GmsFeatures.AdditionalSensors |
|
|
GmsFeatures.Calibration | GmsFeatures.Production | GmsFeatures.SensorWarnings,
|
|
MaximumSensors = 1,
|
|
MaximumEquipment = 2,
|
|
MaximumSensorWarnings = 1,
|
|
MaximumCalibrationHistory = 2,
|
|
MaximumArrayLength = 8,
|
|
MaximumTextLength = 256
|
|
});
|
|
}
|
|
GmsHandle handle = await builder.BuildAsync(cancellationToken).ConfigureAwait(false);
|
|
var instance = new GmsInstance(id, handle, provider, store);
|
|
m_instances.Add(instance);
|
|
await PublishAsync(instance, 0, acquired, cancellationToken).ConfigureAwait(false);
|
|
Console.WriteLine(
|
|
$"INSTANCE {id} root={handle.NodeId} advanced={m_options.Advanced} " +
|
|
$"selectedCUs={handle.ConformanceUnits.Count} store=independent provider=independent");
|
|
foreach (string limitation in handle.Capabilities.Limitations)
|
|
{
|
|
Console.WriteLine($"CONFORMANCE_EXCEPTION {id}: {limitation}");
|
|
}
|
|
}
|
|
Console.WriteLine("Released model 1.0.0/reference 1.0.1; no complete GMS Production facet claim.");
|
|
Console.WriteLine(
|
|
"BOUNDS perInstance: results=256 storedBytes=8388608 metadataBytes=131072 fileBytes=32768 " +
|
|
"metadataHandles=1 fileHandles=1 handleTimeoutSeconds=180");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates simulated observations and publishes real result/data/event flows for a finite running window.
|
|
/// </summary>
|
|
internal async Task RunAsync(CancellationToken cancellationToken)
|
|
{
|
|
using CancellationTokenSource window =
|
|
TimeProviderExtensions.CreateCancellationTokenSource(m_clock, TimeSpan.FromSeconds(m_options.Seconds));
|
|
using var running = CancellationTokenSource.CreateLinkedTokenSource(window.Token, cancellationToken);
|
|
try
|
|
{
|
|
for (int tick = 1; tick <= m_options.Seconds; tick++)
|
|
{
|
|
await TimeProviderExtensions.Delay(m_clock, TimeSpan.FromSeconds(1), running.Token)
|
|
.ConfigureAwait(false);
|
|
foreach (GmsInstance instance in m_instances)
|
|
{
|
|
DateTimeUtc acquired = AcquiredAt();
|
|
await instance.Provider.SetSnapshotAsync(
|
|
GmsSimulationData.Snapshot(instance.Id, m_options.Advanced, tick, acquired), running.Token)
|
|
.ConfigureAwait(false);
|
|
await instance.Handle.RefreshAsync(running.Token).ConfigureAwait(false);
|
|
await PublishAsync(instance, tick, acquired, running.Token).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (window.IsCancellationRequested &&
|
|
!cancellationToken.IsCancellationRequested)
|
|
{
|
|
// Normal finite-window expiry; external cancellation still propagates through host cleanup.
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Uses only the existing per-instance result publisher, never another service or file-transfer engine.
|
|
/// </summary>
|
|
private async ValueTask PublishAsync(
|
|
GmsInstance instance, int tick, DateTimeUtc acquired, CancellationToken cancellationToken)
|
|
{
|
|
if (m_options.Advanced)
|
|
{
|
|
await instance.Handle.Results.PublishAsync(
|
|
GmsSimulationData.Result(instance.Id, true, tick, acquired, intermediate: true), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
await instance.Handle.Results.PublishCorrectionAsync(
|
|
GmsSimulationData.Correction(instance.Id, tick, acquired), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
await instance.Handle.Results.PublishAsync(
|
|
GmsSimulationData.Result(instance.Id, m_options.Advanced, tick, acquired, intermediate: false),
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stamps simulation inputs separately from eventual server and client receipt.
|
|
/// </summary>
|
|
private DateTimeUtc AcquiredAt() => new(m_clock.GetUtcNow().UtcDateTime);
|
|
|
|
/// <summary>
|
|
/// Couples only one native handle to each pair of application-owned providers.
|
|
/// </summary>
|
|
private sealed record GmsInstance(
|
|
string Id, GmsHandle Handle, InMemoryGmsProvider Provider, InMemoryGmsResultStore Store);
|
|
|
|
/// <summary>The explicit simulation and lifetime bounds.</summary>
|
|
private readonly GmsServerOptions m_options;
|
|
|
|
/// <summary>The injected input and delay clock.</summary>
|
|
private readonly TimeProvider m_clock;
|
|
|
|
/// <summary>The injected lifecycle logger.</summary>
|
|
private readonly ILogger m_logger;
|
|
|
|
/// <summary>The separately owned instance resources.</summary>
|
|
private readonly List<GmsInstance> m_instances = [];
|
|
|
|
/// <summary>The actual OPC UA readiness signal.</summary>
|
|
private readonly TaskCompletionSource<bool> m_ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
/// <summary>Prevents repeated application/host disposal from replaying cleanup.</summary>
|
|
private bool m_disposed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Source-generated simulation lifetime diagnostics.
|
|
/// </summary>
|
|
internal static partial class GmsSimulationLog
|
|
{
|
|
/// <summary>
|
|
/// Reports completed cleanup, not just a shutdown request.
|
|
/// </summary>
|
|
[LoggerMessage(EventId = GmsServerEventIds.GmsSimulation, Level = LogLevel.Information,
|
|
Message = "Released {Count} GMS handles, emptied stores and released their provider references.")]
|
|
public static partial void InstancesReleased(this ILogger logger, int count);
|
|
}
|
|
}
|