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

275 lines
12 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 Opc.Ua;
using Opc.Ua.MachineTool.WMTP;
namespace MachineToolSample.Server
{
/// <summary>
/// An explicitly selected simulation adapter for one peripheral, never a physical device driver.
/// </summary>
internal sealed class DemoPeripheral :
IWmtpDeviceProvider, IWmtpMeasurementProvider, IWmtpCalibrationProvider,
IWmtpDeviceTimeProvider, IWmtpTransferProvider
{
/// <summary>
/// Creates independent simulated acquisition and request state.
/// </summary>
public DemoPeripheral(ServerOptions options, TimeProvider clock)
{
m_options = options;
m_clock = clock;
m_initialTime = clock.GetUtcNow();
foreach (WmtpMeasurementKind kind in MeasurementKinds)
{
m_settings.Add(kind.ToString(), (1000, Variant.From(0d)));
}
}
/// <summary>
/// Gets the eight released measurement kinds without reflection-based enumeration.
/// </summary>
internal static ArrayOf<WmtpMeasurementKind> MeasurementKinds { get; } =
[
WmtpMeasurementKind.Temperature, WmtpMeasurementKind.RotationSpeed,
WmtpMeasurementKind.Humidity, WmtpMeasurementKind.Acceleration,
WmtpMeasurementKind.Force, WmtpMeasurementKind.Torque,
WmtpMeasurementKind.Position, WmtpMeasurementKind.Dimension
];
/// <inheritdoc/>
public ValueTask<WmtpDeviceSnapshot> ReadAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
lock (m_gate)
{
DateTimeUtc time = m_clock.GetUtcNow() + m_deviceOffset;
var life = new WmtpLifetime
{
Start = Variant.From(100d),
Remaining = Variant.From(80d),
Limit = Variant.From(0d),
EngineeringUnits = Unit(4740434, "h")
};
return new ValueTask<WmtpDeviceSnapshot>(new WmtpDeviceSnapshot
{
Observation = new DataValue(Variant.From(1u), StatusCodes.Good, time),
CalibrationMode = m_calibrating,
Battery = new DataValue(Variant.From(75d), StatusCodes.Good, time),
LicenseInformation = "Simulation entitlement; transferred bytes do not activate a license.",
LicenseValidity = life,
LastService = m_initialTime,
NextServiceDate = m_clock.GetUtcNow().AddDays(30),
LifeCycleStatus = 0,
RemainingLifetime = life,
RemainingCycles = life with { EngineeringUnits = Unit(4404786, "cycle") },
NextService = life,
OperationHours = 12,
OperationSinceService = 60000
});
}
}
/// <inheritdoc/>
public ValueTask<WmtpMeasurementSample> ReadAsync(
string sensor, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
lock (m_gate)
{
if (!m_settings.ContainsKey(sensor))
{
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown, "Unknown simulated sensor.");
}
return new ValueTask<WmtpMeasurementSample>(new WmtpMeasurementSample
{
Value = new DataValue(Variant.From(21.5), StatusCodes.Good,
m_options.FreezeMeasurements ? m_initialTime : m_clock.GetUtcNow() + m_deviceOffset),
Index = 1,
MeasurementPeriod = 50
});
}
}
/// <inheritdoc/>
public ValueTask<StatusCode> ConfigureAsync(
string sensor, uint triggerMilliseconds, Variant delta, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
lock (m_gate)
{
if (!m_settings.ContainsKey(sensor))
{
return new ValueTask<StatusCode>(StatusCodes.BadNodeIdUnknown);
}
StatusCode accepted = Accept(WmtpOperation.ConfigureMeasurement, cancellationToken);
if (StatusCode.IsGood(accepted))
{
m_settings[sensor] = (triggerMilliseconds, delta.Copy());
}
return new ValueTask<StatusCode>(accepted);
}
}
/// <inheritdoc/>
public ValueTask<StatusCode> SetCalibrationAsync(bool enabled, CancellationToken cancellationToken = default)
{
lock (m_gate)
{
StatusCode accepted = Accept(WmtpOperation.Calibrate, cancellationToken);
if (StatusCode.IsGood(accepted))
{
m_calibrating = enabled;
}
return new ValueTask<StatusCode>(accepted);
}
}
/// <inheritdoc/>
public ValueTask<StatusCode> SetTimeAsync(
DateTimeUtc utcTime, TimeZoneDataType timeZone, CancellationToken cancellationToken = default)
{
lock (m_gate)
{
StatusCode accepted = Accept(WmtpOperation.SetTime, cancellationToken);
if (StatusCode.IsGood(accepted))
{
m_deviceOffset = utcTime - new DateTimeUtc(m_clock.GetUtcNow());
Console.WriteLine(FormattableString.Invariant(
$"SIMULATED_TIME_ACCEPTED utc={utcTime} offsetMinutes={timeZone.Offset}"));
}
return new ValueTask<StatusCode>(accepted);
}
}
/// <inheritdoc/>
public ValueTask<StatusCode> TransferAsync(
WmtpTransferKind kind, ByteString payload, CancellationToken cancellationToken = default)
{
lock (m_gate)
{
StatusCode accepted = payload.Length is < 1 or > 1024
? StatusCodes.BadEncodingLimitsExceeded
: Accept(kind == WmtpTransferKind.Firmware
? WmtpOperation.TransferFirmware : WmtpOperation.TransferLicense, cancellationToken);
if (StatusCode.IsGood(accepted))
{
Console.WriteLine(FormattableString.Invariant(
$"SIMULATED_TRANSFER_ACCEPTED kind={kind} size={payload.Length}; no installation/activation."));
}
return new ValueTask<StatusCode>(accepted);
}
}
/// <summary>
/// Uses the released measurement kinds and explicit UN/CEFACT units.
/// </summary>
internal static WmtpMeasurementDefinition Definition(WmtpMeasurementKind kind, bool advanced)
{
(int id, string symbol) = kind switch
{
WmtpMeasurementKind.Temperature => (4408652, "degC"),
WmtpMeasurementKind.RotationSpeed => (5394509, "rpm"),
WmtpMeasurementKind.Humidity => (20529, "%"),
WmtpMeasurementKind.Acceleration => (5067595, "m/s2"),
WmtpMeasurementKind.Force => (5129559, "N"),
WmtpMeasurementKind.Torque => (20053, "N.m"),
WmtpMeasurementKind.Position => (5067858, "m"),
_ => (5066068, "mm")
};
return new WmtpMeasurementDefinition
{
Name = kind.ToString(),
Kind = kind,
EngineeringUnits = Unit(id, symbol),
Definition = "Application-owned simulation; not a hardware reading.",
Features = advanced ? WmtpMeasurementFeatures.All : WmtpMeasurementFeatures.None,
EURange = new Opc.Ua.Range { Low = 0, High = 100 },
InstrumentRange = new Opc.Ua.Range { Low = -100, High = 200 },
ValuePrecision = 0.125,
InternalUpdateInterval = 25,
RelativeUncertainty = 0.5,
AbsoluteUncertainty = 0.25,
High = 80,
HighHigh = 90,
StaleAfter = TimeSpan.FromSeconds(2)
};
}
private StatusCode Accept(WmtpOperation operation, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (!m_options.AllowCommands)
{
return StatusCodes.BadUserAccessDenied;
}
return m_options.RefuseCommands || !m_accepted.Add(operation)
? StatusCodes.BadResourceUnavailable : StatusCodes.Good;
}
private static EUInformation Unit(int id, string symbol) => new()
{
NamespaceUri = "http://www.opcfoundation.org/UA/units/un/cefact",
UnitId = id,
DisplayName = new LocalizedText(symbol)
};
private readonly ServerOptions m_options;
private readonly TimeProvider m_clock;
private readonly DateTimeUtc m_initialTime;
private readonly Lock m_gate = new();
private readonly HashSet<WmtpOperation> m_accepted = [];
private readonly Dictionary<string, (uint Interval, Variant Delta)> m_settings = new(StringComparer.Ordinal);
private bool m_calibrating;
private TimeSpan m_deviceOffset;
}
/// <summary>
/// Makes simulated writes opt-in and never authorizes aborting a different session's work.
/// </summary>
internal sealed class DemoAuthorization(ServerOptions options) : IWmtpAuthorization
{
/// <inheritdoc/>
public ValueTask<StatusCode> AuthorizeAsync(
WmtpRequest request, WmtpOperation operation, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
bool sameOwner = request.ActiveSessionId.IsNull || request.ActiveSessionId == request.SessionId;
bool allowed = operation == WmtpOperation.ReadRecords ||
(options.AllowCommands && (operation != WmtpOperation.Abort || sameOwner));
return new ValueTask<StatusCode>(allowed ? StatusCodes.Good : StatusCodes.BadUserAccessDenied);
}
}
}