Files
Marc Schier 0374e19121 Add MachineTool, WMTP, GMS and CranesHoists companions (#4765)
# 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.
2026-10-15 15:15:46 +02:00

325 lines
20 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Client.StateMachines;
using Opc.Ua.CranesHoists;
using Opc.Ua.CranesHoists.Client;
using Opc.Ua.GMS.Client;
using Opc.Ua.GMS.Contracts;
using Opc.Ua.MachineTool;
using Opc.Ua.MachineTool.Client;
using Opc.Ua.MachineTool.WMTP;
using Opc.Ua.Vision;
using Opc.Ua.Vision.Client;
using Opc.Ua.Vision.MachineVision;
using ClientSession = Opc.Ua.Client.ISession;
namespace CompanionSuite
{
/// <summary>
/// Reads meaningful, independently acquired state through all typed clients on one real encrypted session.
/// </summary>
internal static class WorkcellReader
{
/// <summary>
/// Verifies the sample's actual per-instance contents, optional standard vision execution and cleanup.
/// </summary>
internal static async Task ReadAsync(
ClientSession session, ITelemetryContext telemetry, SuiteOptions options, CancellationToken ct)
{
Require(session.ConfiguredEndpoint.Description.SecurityMode == MessageSecurityMode.SignAndEncrypt,
"SignAndEncrypt was not negotiated.");
Console.WriteLine($"CONNECTED security={session.ConfiguredEndpoint.Description.SecurityMode}");
var tools = new MachineToolClient(session, telemetry);
await using var toolsLifetime = tools.ConfigureAwait(false);
var gms = new GmsClient(session, telemetry);
await using var gmsLifetime = gms.ConfigureAwait(false);
var cranes = new CranesHoistsClient(session, telemetry);
var vision = new MachineVisionClient(session, telemetry);
ArrayOf<MachineToolDeviceEntry> allMachines = await tools.DiscoverAsync(cancellationToken: ct)
.ConfigureAwait(false);
MachineToolDeviceEntry[] mills = allMachines.ToArray()!.Where(value =>
value.TypeDefinitionId == ExpandedNodeId.ToNodeId(
Opc.Ua.MachineTool.ObjectTypeIds.MachineToolType, session.NamespaceUris))
.OrderBy(value => value.BrowseName.Name, StringComparer.Ordinal).ToArray();
MachineToolDeviceEntry[] peripherals = allMachines.ToArray()!.Where(value =>
value.Kind == MachineToolDeviceKind.WirelessPeripheral)
.OrderBy(value => value.BrowseName.Name, StringComparer.Ordinal).ToArray();
GmsDeviceEntry[] systems = (await gms.DiscoverAsync(ct).ConfigureAwait(false))
.ToArray()!.OrderBy(value => value.BrowseName.Name, StringComparer.Ordinal).ToArray();
CraneEntry[] craneSystems = (await cranes.DiscoverAsync(ct).ConfigureAwait(false))
.ToArray()!.OrderBy(value => value.BrowseName.Name, StringComparer.Ordinal).ToArray();
var inspectionSystems = new List<VisionNodeEntry>();
await foreach (VisionNodeEntry entry in vision.EnumerateSystemsAsync(cancellationToken: ct)
.ConfigureAwait(false))
{
inspectionSystems.Add(entry);
}
inspectionSystems.Sort((left, right) =>
string.Compare(left.BrowseName.Name, right.BrowseName.Name, StringComparison.Ordinal));
var assets = new List<VisionNodeEntry>();
await foreach (VisionNodeEntry entry in vision.EnumerateAssetsAsync(cancellationToken: ct)
.ConfigureAwait(false))
{
assets.Add(entry);
}
assets.Sort((left, right) =>
string.Compare(left.BrowseName.Name, right.BrowseName.Name, StringComparison.Ordinal));
Require(mills.Length == options.Instances && peripherals.Length == options.Instances &&
systems.Length == options.Instances && craneSystems.Length == options.Instances &&
inspectionSystems.Count == options.Instances && assets.Count == options.Instances,
"The endpoint did not expose the complete sample workcell.");
var roots = new HashSet<NodeId>();
for (int index = 0; index < options.Instances; index++)
{
int number = index + 1;
Require(roots.Add(mills[index].NodeId) && roots.Add(peripherals[index].NodeId) &&
roots.Add(systems[index].NodeId) && roots.Add(craneSystems[index].NodeId) &&
roots.Add(inspectionSystems[index].NodeId) && roots.Add(assets[index].NodeId),
"Independent families share a root identifier.");
await ReadMachineAsync(tools, mills[index].NodeId, number, ct).ConfigureAwait(false);
await ReadPeripheralAsync(tools, peripherals[index].NodeId, number, ct).ConfigureAwait(false);
await ReadGmsAsync(gms, systems[index].NodeId, number, ct).ConfigureAwait(false);
await ReadCraneAsync(cranes, craneSystems[index].NodeId, number, ct).ConfigureAwait(false);
await ReadVisionAsync(vision, inspectionSystems[index].NodeId, assets[index].NodeId,
number, options.Exercise, ct)
.ConfigureAwait(false);
}
DataValue conformance = await session.ReadValueAsync(
Opc.Ua.VariableIds.Server_ServerCapabilities_ConformanceUnits, ct).ConfigureAwait(false);
Require(conformance.WrappedValue.TryGetValue(out ArrayOf<QualifiedName> units),
"The shared conformance catalog is not readable.");
Require(!units.ToArray()!.Any(value => value.Name == "GMS GMS_ProductionStatisticsType"),
"The undefined upstream GMS production-statistics unit must not be claimed.");
Console.WriteLine(
$"WORKCELL_VERIFIED families=5 instances={options.Instances} conformanceUnits={units.Count}");
}
/// <summary>
/// Reads retained production and actual tooling through generated MachineTool descendants.
/// </summary>
private static async Task ReadMachineAsync(
MachineToolClient client, NodeId node, int number, CancellationToken ct)
{
MachineToolReadSnapshot values = await client.ReadSnapshotAsync(node, ct).ConfigureAwait(false);
PrintValue("MACHINE active program", values.ActiveProgramName);
Require(values.ActiveProgramName.WrappedValue.TryGetValue(out string program) &&
program == "CELL" + number + ".NC", "A machine returned another instance's active program.");
MachineToolProductionReadSnapshot production = await client.ReadProductionAsync(node,
cancellationToken: ct).ConfigureAwait(false);
Require(production.Jobs.Count == 1, "The sample production plan was not populated.");
PrintValue("MACHINE job", production.Jobs[0].Identifier);
MachineToolEquipmentReadSnapshot equipment = await client.ReadEquipmentAsync(node,
cancellationToken: ct).ConfigureAwait(false);
Require(equipment.Tools.Count == 1, "The sample tool inventory was not populated.");
Require(equipment.Tools[0].Identifier.WrappedValue.TryGetValue(out string tool) &&
tool == "Cutter" + number,
"A machine returned another instance's tooling.");
PrintValue("MACHINE cutter", equipment.Tools[0].Identifier);
}
/// <summary>
/// Reads measurement quality and copied records without consuming or clearing the dataset.
/// </summary>
private static async Task ReadPeripheralAsync(
MachineToolClient client, NodeId node, int number, CancellationToken ct)
{
WmtpPeripheralClient peripheral = client.GetWirelessPeripheral(node);
WmtpPeripheralSnapshot state = await peripheral.ReadSnapshotAsync(ct).ConfigureAwait(false);
PrintValue("WMTP battery", state.Battery);
ArrayOf<WmtpMeasurementReading> values = await peripheral.ReadMeasurementsAsync(cancellationToken: ct)
.ConfigureAwait(false);
Require(values.Count == 1, "The sample measurement was not exposed.");
Require(values[0].Sample.WrappedValue.TryGetValue(out double temperature) &&
temperature == 20.0 + number, "A peripheral returned another instance's acquisition.");
PrintValue("WMTP temperature", values[0].Sample);
WmtpRecordClient records = await peripheral.GetWorkRecordsAsync(ct).ConfigureAwait(false);
ArrayOf<WMTPOutputDataType> first = await records.ReadAsync(new WmtpRecordQuery(), cancellationToken: ct)
.ConfigureAwait(false);
ArrayOf<WMTPOutputDataType> second = await records.ReadAsync(new WmtpRecordQuery(), cancellationToken: ct)
.ConfigureAwait(false);
Require(first.Count == 1 && second.Count == 1 && first[0].ActualValue == 200.0 + number &&
second[0].Index == first[0].Index, "A passive record query consumed or confused a dataset.");
Console.WriteLine($"WMTP copied record index={first[0].Index} value={first[0].ActualValue}");
}
/// <summary>
/// Uses GMS qualification, native active-sensor references, result metadata and the shared file client.
/// </summary>
private static async Task ReadGmsAsync(GmsClient client, NodeId node, int number, CancellationToken ct)
{
GmsSystemReadSnapshot snapshot = await client.ReadSnapshotAsync(node, ct).ConfigureAwait(false);
GmsEquipmentReadSnapshot equipment = await client.ReadEquipmentAsync(node, ct).ConfigureAwait(false);
Require(equipment.Sensors.Count == 1, "The native qualified sensor is missing.");
MachineToolToolLifeReadSnapshot qualified = equipment.Sensors[0].Inherited.Life.ToArray()!.Single();
Require(qualified.Value.WrappedValue.TryGetValue(out double hours) && hours == 10.0 + number,
"The native qualification countdown did not match its provider.");
PrintValue("GMS qualified hours", qualified.Value);
Require(snapshot.ToolMonitoring != null &&
snapshot.ToolMonitoring.ActiveTool.Value.WrappedValue.TryGetValue(out ArrayOf<NodeId> active) &&
active.Count == 1 && active[0] == equipment.Sensors[0].Inherited.NodeId,
"The GMS active sensor reference escaped its own inventory.");
GmsResultClient results = client.GetResults(node);
var result = await results.GetLatestAsync(ct).ConfigureAwait(false);
Require(result.ResultMetaData!.ResultId == "Result" + number &&
result.ResultContent[0].TryGetValue(out double value) && value == 100.0 + number,
"The GMS result belongs to another instance.");
ByteString content = await results.DownloadAsync("Result" + number, ct).ConfigureAwait(false);
Require(content.Span.SequenceEqual(ByteString.From((byte)number, 7, 9).Span),
"The GMS result file did not match its metadata.");
Console.WriteLine($"GMS result={result.ResultMetaData.ResultId} payload={content.Length} bytes");
}
/// <summary>
/// Reads position, load and protection from native crane types; there are no invented motion commands.
/// </summary>
private static async Task ReadCraneAsync(
CranesHoistsClient client, NodeId node, int number, CancellationToken ct)
{
CraneSystemSnapshot snapshot = await client.ReadSystemAsync(node, ct).ConfigureAwait(false);
CraneElementSnapshot axis = snapshot.Elements.ToArray()!.Single(value =>
value.Entry.Kind == CraneElementKind.Axis);
CraneSignalValue load = axis.Signals.ToArray()!.Single(value => value.Signal == CraneSignal.LoadMass);
CraneSignalValue position = axis.Signals.ToArray()!
.Single(value => value.Signal == CraneSignal.ActualPosition);
Require(load.Value.WrappedValue.TryGetValue(out double mass) && mass == 100.0 + number,
"The crane load is not this instance's acquisition.");
PrintValue("CRANE load", load.Value);
PrintValue("CRANE position", position.Value);
Require(await client.Axis(axis.Entry.NodeId).GetParameterSetAsync(client.Telemetry, ct)
.ConfigureAwait(false) != null, "The generated crane-axis proxy could not resolve ParameterSet.");
CraneElementSnapshot protection = snapshot.Elements.ToArray()!.Single(value =>
value.Entry.Kind == CraneElementKind.ProtectiveFunction);
PrintValue("CRANE overload active",
protection.Signals.ToArray()!.Single(value => value.Signal == CraneSignal.Active).Value);
Require(protection.Relationships.ToArray()!.Any(value => value.Target == axis.Entry.NodeId),
"Crane protection did not refer to its own axis.");
}
/// <summary>
/// Reads a meaningful simulated inspection result; standard job execution requires explicit opt-in.
/// </summary>
private static async Task ReadVisionAsync(
MachineVisionClient client, NodeId node, NodeId asset, int number, bool exercise, CancellationToken ct)
{
ArrayOf<NodeId> sameEntity = await client.GetSameEntityNodesAsync(asset, ct).ConfigureAwait(false);
Require(sameEntity.Count == 1 && sameEntity[0] == node,
"The MachineVision asset refers to another inspection runtime.");
MachineVisionAssetSnapshot assetState = await client.ReadAssetAsync(asset, cancellationToken: ct)
.ConfigureAwait(false);
Require(assetState.IsComplete, "The sample asset read exceeded its explicit bounds.");
MachineVisionAssetValue temperature = assetState.Values.ToArray()!.Single(value =>
value.BrowsePath.Count == 2 && value.BrowsePath[0].Name == "Health" &&
value.BrowsePath[1].Name == "Temperature");
Require(temperature.Value.WrappedValue.TryGetValue(out double degrees) && degrees == 20.0 + number,
"The MachineVision asset returned another camera's temperature.");
PrintValue("VISION asset temperature", temperature.Value);
MachineVisionSystemClient system = client.System(node);
VisionStateMachineTypeClient control = await system.ControlAsync(ct).ConfigureAwait(false);
FiniteStateSnapshot state = await control.GetCurrentFiniteStateAsync(ct).ConfigureAwait(false);
Console.WriteLine($"VISION state={state.CurrentState.Text} root={node}");
ResultManagementTypeClient results = await system.ResultsAsync(ct).ConfigureAwait(false);
(uint handle, ResultDataType result, int error) =
await results.GetResultByIdAsync(new ResultIdDataType { Id = "Inspection" + number }, 0, ct)
.ConfigureAwait(false);
Require(error == 0, "The standard vision result query failed.");
try
{
Require(result.ResultContent[0].TryGetValue(out int resultValue) && resultValue == number,
"An inspection returned another camera's result.");
Console.WriteLine($"VISION result={result.ResultId.Id} classification={resultValue}");
}
finally
{
Require(await results.ReleaseResultHandleAsync(handle, ct).ConfigureAwait(false) == 0,
"The vision metadata handle did not release.");
}
if (!exercise)
{
return;
}
Require(await control.SelectModeAutomaticAsync(ct).ConfigureAwait(false) == 0,
"The simulated provider refused automatic mode.");
VisionAutomaticModeStateMachineTypeClient automatic = await system.AutomaticAsync(ct).ConfigureAwait(false);
(JobIdDataType job, int jobError) = await automatic.StartSingleJobAsync(
new MeasIdDataType { Id = "SelfCheck" + number }, new PartIdDataType { Id = "SamplePart" + number },
new RecipeIdExternalDataType(), new ProductIdDataType(), [], ct).ConfigureAwait(false);
Require(jobError == 0, "The simulated provider refused the explicit single inspection.");
using var window = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(window.Token, ct);
while ((await automatic.GetCurrentFiniteStateAsync(linked.Token).ConfigureAwait(false)).CurrentState.Text !=
"Ready")
{
await Task.Delay(20, linked.Token).ConfigureAwait(false);
}
(uint jobHandle, ResultDataType jobResult, int resultError) = await results.GetResultByIdAsync(
new ResultIdDataType { Id = "Job-" + job.Id }, 0, ct).ConfigureAwait(false);
Require(resultError == 0, "The accepted simulated job produced no accessible result.");
try
{
Require(jobResult.JobId.Id == job.Id, "The vision result is not correlated to the accepted job.");
Console.WriteLine($"EXPLICIT_SIMULATED_JOB job={job.Id} result={jobResult.ResultId.Id}");
}
finally
{
Require(await results.ReleaseResultHandleAsync(jobHandle, ct).ConfigureAwait(false) == 0,
"The job-result metadata handle did not release.");
}
}
/// <summary>
/// Reports original quality and acquisition time without equating a raw value with Good quality.
/// </summary>
private static void PrintValue(string label, in DataValue value)
{
Require(!value.IsNull && !StatusCode.IsBad(value.StatusCode), label + " has no usable acquisition.");
Console.WriteLine(
$"{label}: {value.WrappedValue}; quality={value.StatusCode}; source={value.SourceTimestamp:O}");
}
/// <summary>
/// Makes partial failures visible in the process exit code instead of printing a successful model count.
/// </summary>
private static void Require(bool condition, string message)
{
if (!condition)
{
throw new InvalidOperationException(message);
}
}
}
}