mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-19 07:02:16 +08:00
dbee7b9988
# Description Follow-up to #4619. It was prepared stacked on #4619 and is now one commit on top of master, with #4619's review changes in place. `AxisSnapshot.Engineering`, `LoadSnapshot.MassEngineering` and `GearSnapshot.GearRatio` are part of the snapshot contracts and documented in `docs/Robotics.md` (the URML capability manifest takes the joint limits from them), but `RoboticsClient` never set them: every axis came back without units or limits, every load without the unit of its mass and every gear without its ratio. OPC 40010-1 declares `ActualPosition` (mandatory), `ActualSpeed` and `ActualAcceleration` (optional) below the axis's DI `ParameterSet`, and the load's `Mass`, as `AnalogUnitType` variables with a mandatory `EngineeringUnits` and an optional `EURange` property, and `GearRatio` as a mandatory `RationalNumber` variable of the gear. The client now reads them: - **Axis:** `ReadAxisAsync`, and with it `ReadSystemAsync`, resolves the `EngineeringUnits` and `EURange` of the three axis values with one TranslateBrowsePathsToNodeIds and reads them with one Read per axis (none when nothing resolves) into `PositionUnit`, `SpeedUnit`, `AccelerationUnit` and `Limits.Position/Speed/Acceleration`. - **Load:** `Mass` is resolved and read together with its `EngineeringUnits` and `EURange` (`MassEngineering`), replacing the separate resolve and read. - **Gear:** `GearRatio` is read in the same request as `Pitch`. - Loads and gears cost no extra request. - A property the server does not publish, whose read fails or whose value has a bad status stays `null`. The structures go through the existing `ToStructure<T>`, which checks `StatusCode.IsGood` as for `MotorTemperatureEngineering`. The rest of the snapshot is still returned. - Because `Mass` and `Pitch` are now part of that request, a failed `Mass` or `Pitch` read keeps the server's status in the `DataValue`, like the axis values and `MotorTemperature`. Before, `Session.ReadValueAsync` threw and failed the whole snapshot read. The public API does not change. ## Changes - `src/Opc.Ua.Robotics.Client/RoboticsClient.Topology.cs`: - new `ReadAxisEngineeringAsync`, called from `ReadAxisAsync`; - `ReadLoadAsync` and `ReadGearAsync` use the existing `ReadChildrenAsync`. - `docs/Robotics.md`: - describes the members and their null cases; - adds them to the values that carry no NodeId; - names `AxisSnapshot.Engineering` instead of the non-existent `AxisSnapshot.Limits` in the URML section. ## Tests - **New, unit** (`Opc.Ua.Robotics.Tests`, `RoboticsClientFacadeTests`): - `EngineeringValuesAndTheGearRatioComeFromTheVariablesTheyDescribe`: the values are checked against same-named decoys below the axis and a `GearRatio` in namespace 0. - `AbsentOrUnreadableEngineeringValuesAndGearRatiosStayNull`: a missing `EURange`, a load without engineering properties, a failing unit read and a failing ratio read. - `BadQualityEngineeringValuesAndGearRatiosReadAsNoStructure`: a range, a unit and a ratio with a bad status read as `null`. - `AFailedMassOrPitchReadKeepsTheStatusAndTheSnapshot`. - `AnAxisWithoutAParameterSetHasNoEngineeringValues`. - **New, end-to-end** (`RoboticsClientServerE2eTests.EngineeringValuesAndGearRatiosAreReadAsync`): - the test cell now publishes a unit and range for `ActualSpeed`, a unit without a range for `ActualAcceleration`, a range for the flange load's mass and a tool load with a mass only; - the test reads the units, ranges and gear ratios back through `ReadSystemAsync` and `ReadAxisAsync`. - **Fail without the fix:** with `src/Opc.Ua.Robotics.Client` from master, five of the six new tests fail. Four get `null` units, ranges or ratios. `AFailedMassOrPitchReadKeepsTheStatusAndTheSnapshot` fails with `ServiceResultException` `Bad_NodeIdUnknown`. `AnAxisWithoutAParameterSetHasNoEngineeringValues` passes there by design: it pins that an axis without a `ParameterSet` still reads without error. - `Opc.Ua.Robotics.Tests` passed 661/661 on net10.0 and 601/601 on net8.0 (macOS). - `Opc.Ua.Robotics.Client` builds for net48, net8.0, net9.0 and net10.0 with 0 warnings. - `dotnet format` (whitespace, style, analyzers) on the changed files: nothing on the changed lines beyond the `IDE0002` info that the existing `RoboticsSessionHarness.Ua(Opc.Ua.BrowseNames.…)` calls in the same test class also get. - Changed lines covered: 52/52 (100 %). - Fork CI (full matrix incl. macOS) and CodeQL: [CI run 37147764118](https://github.com/biancode/UA-.NETStandard/actions/runs/37147764118) and [CodeQL run 37147766527](https://github.com/biancode/UA-.NETStandard/actions/runs/37147766527). CodeQL is green; CI is green except `Opc.Ua.Interop.Tests` (`LegacyClientExtendedCheckAsync("ComplexTypes")` / `LoadAndDecodeLegacyComplexTypesAsync`), which is flaky on master since #4630 (details in #4617), and except one Windows run of `StartIsIdempotentAndDisposeStopsListenerAsync` in `Opc.Ua.Redundancy.Kubernetes.Tests` (HttpListener prefix already registered), which this change does not touch either. ## Notes for review - `ReadAxisAsync` costs one TranslateBrowsePathsToNodeIds and at most one Read more per axis. The other snapshots keep their request count. - `AxisSnapshot.Engineering`, `LoadSnapshot.MassEngineering` and `GearSnapshot.GearRatio` are read once and carry no NodeId, like `MotorTemperatureEngineering`. ## Related Issues - Fixes #4660 - Follows up #4619 ## Checklist - [x] 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. - [x] 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. (Affected suite on net10.0 and net8.0 locally on macOS; .NET Framework is covered by the fork CI's Windows net48 legs.) - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
874 lines
44 KiB
C#
874 lines
44 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.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Client.Subscriptions;
|
|
using Opc.Ua.Client.Subscriptions.Streaming;
|
|
using Opc.Ua.Configuration;
|
|
using Opc.Ua.Di.Server.Builders;
|
|
using Opc.Ua.Robotics.Server;
|
|
using Opc.Ua.Robotics.Server.Builders;
|
|
using Opc.Ua.Server.Hosting;
|
|
using Opc.Ua.Tests;
|
|
|
|
namespace Opc.Ua.Robotics.Client.Tests
|
|
{
|
|
/// <summary>
|
|
/// Drives a hosted OPC 40010-1 server built with the Robotics builders with the
|
|
/// Robotics client over a real opc.tcp session. Unlike the facade tests against
|
|
/// a scripted session, this decides whether the client finds what a server
|
|
/// actually publishes: namespace indices, the DI ParameterSet, placeholders and
|
|
/// the members of the task control state machine.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("Robotics")]
|
|
[NonParallelizable]
|
|
public sealed class RoboticsClientServerE2eTests
|
|
{
|
|
private ITelemetryContext Telemetry { get; } = NUnitTelemetryContext.Create();
|
|
|
|
[OneTimeSetUp]
|
|
public async Task StartAsync()
|
|
{
|
|
m_serverUrl = "opc.tcp://localhost:" +
|
|
GetFreeTcpPort().ToString(System.Globalization.CultureInfo.InvariantCulture) +
|
|
"/RoboticsE2e";
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services
|
|
.AddOpcUa()
|
|
.AddServer(ConfigureServer)
|
|
.AddRobotics()
|
|
.ConfigureRobotics(ConfigureCellAsync);
|
|
m_provider = services.BuildServiceProvider();
|
|
m_hostedServices = [.. m_provider.GetServices<IHostedService>()];
|
|
foreach (IHostedService service in m_hostedServices)
|
|
{
|
|
await service.StartAsync(CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
await WaitForCellAsync().ConfigureAwait(false);
|
|
|
|
m_clientConfiguration = await CreateClientConfigurationAsync().ConfigureAwait(false);
|
|
EndpointDescription endpoint = await WaitForEndpointAsync().ConfigureAwait(false);
|
|
var sessionFactory = new DefaultSessionFactory(Telemetry)
|
|
{
|
|
SubscriptionEngineFactory = DefaultSubscriptionEngineFactory.Instance
|
|
};
|
|
m_session = await sessionFactory.CreateAsync(
|
|
m_clientConfiguration,
|
|
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(m_clientConfiguration)),
|
|
updateBeforeConnect: false,
|
|
sessionName: "robotics-e2e",
|
|
sessionTimeout: 60000,
|
|
identity: new UserIdentity(new AnonymousIdentityToken()),
|
|
preferredLocales: default,
|
|
ct: CancellationToken.None).ConfigureAwait(false);
|
|
Assert.That(m_session.TryGetSubscriptionManager(out ISubscriptionManager? manager), Is.True);
|
|
m_streaming = new StreamingSubscription(manager!);
|
|
m_client = m_session.Robotics(Telemetry);
|
|
}
|
|
|
|
[OneTimeTearDown]
|
|
public async Task StopAsync()
|
|
{
|
|
if (m_session != null)
|
|
{
|
|
if (m_session.Connected)
|
|
{
|
|
await m_session.CloseAsync(1000, true).ConfigureAwait(false);
|
|
}
|
|
if (m_streaming != null)
|
|
{
|
|
await m_streaming.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
m_session.Dispose();
|
|
}
|
|
if (m_hostedServices != null)
|
|
{
|
|
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
|
foreach (IHostedService service in m_hostedServices.AsEnumerable().Reverse())
|
|
{
|
|
await service.StopAsync(stop.Token).ConfigureAwait(false);
|
|
}
|
|
}
|
|
if (m_provider != null)
|
|
{
|
|
await m_provider.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
(m_clientConfiguration?.CertificateManager as IDisposable)?.Dispose();
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReadSystemReturnsTheValuesTheServerPublishesAsync()
|
|
{
|
|
RoboticsTopologySnapshot snapshot = await m_client.ReadSystemAsync(
|
|
await SystemIdAsync().ConfigureAwait(false)).ConfigureAwait(false);
|
|
|
|
Assert.That(snapshot.Controllers, Has.Count.EqualTo(1));
|
|
Assert.That(snapshot.Axes, Has.Count.EqualTo(6));
|
|
Assert.That(snapshot.PowerTrains, Has.Count.EqualTo(6));
|
|
Assert.That(snapshot.SafetyStates, Has.Count.EqualTo(1));
|
|
Assert.That(snapshot.TaskControls, Has.Count.EqualTo(1));
|
|
Assert.That(snapshot.TaskModules, Has.Count.EqualTo(3));
|
|
|
|
// Process values below DI:ParameterSet, with a good status.
|
|
for (int ii = 1; ii < s_axes.Length; ii++)
|
|
{
|
|
AxisSnapshot axis = snapshot.Axes.ToArray()!
|
|
.Single(a => a.Identification.BrowseName.Name == s_axes[ii]);
|
|
Assert.That(StatusCode.IsGood(axis.State.ActualPosition.StatusCode), Is.True, s_axes[ii]);
|
|
Assert.That(axis.State.ActualPosition.WrappedValue.TryGetValue(out double position), Is.True,
|
|
s_axes[ii]);
|
|
Assert.That(position, Is.EqualTo(s_home[ii]), s_axes[ii]);
|
|
Assert.That(axis.MotionProfile, Is.EqualTo(AxisMotionProfileEnumeration.ROTARY));
|
|
}
|
|
|
|
// Identification with DI browse names, members with Robotics browse names.
|
|
MotionDeviceSnapshot robot = snapshot.MotionDevices[0];
|
|
Assert.That(robot.Identification.Manufacturer.Text, Is.EqualTo("Acme Robotics"));
|
|
Assert.That(robot.Identification.SerialNumber, Is.EqualTo("AR6-0001"));
|
|
Assert.That(robot.Identification.Model.Text, Is.EqualTo("AR-6"));
|
|
Assert.That(robot.Category, Is.EqualTo(MotionDeviceCategoryEnumeration.ARTICULATED_ROBOT));
|
|
Assert.That(robot.SpeedOverride.WrappedValue.TryGetValue(out double speed), Is.True);
|
|
Assert.That(speed, Is.EqualTo(100.0));
|
|
Assert.That(snapshot.Controllers[0].Identification.SerialNumber, Is.EqualTo("RC9-0001"));
|
|
LoadSnapshot flange = snapshot.Loads.ToArray()!.Single(l => l.NodeId == robot.FlangeLoadId);
|
|
Assert.That(flange.Mass.WrappedValue.TryGetValue(out double mass), Is.True);
|
|
Assert.That(mass, Is.EqualTo(6.5));
|
|
|
|
// Safety and task values below the ParameterSet, function and module names directly.
|
|
SafetyStateSnapshot safety = snapshot.SafetyStates[0];
|
|
Assert.That(safety.EmergencyStop.WrappedValue.TryGetValue(out bool estop), Is.True);
|
|
Assert.That(estop, Is.False);
|
|
Assert.That(safety.OperationalMode.WrappedValue.TryGetValue(out int mode), Is.True);
|
|
Assert.That(mode, Is.EqualTo((int)OperationalModeEnumeration.AUTOMATIC));
|
|
Assert.That(safety.ProtectiveStop.WrappedValue.TryGetValue(out bool protective), Is.True);
|
|
Assert.That(protective, Is.False);
|
|
Assert.That(safety.EmergencyStopFunctions, Has.Count.EqualTo(1));
|
|
Assert.That(safety.EmergencyStopFunctions[0].Name, Is.EqualTo("Pendant"));
|
|
Assert.That(safety.ProtectiveStopFunctions, Has.Count.EqualTo(1));
|
|
Assert.That(safety.ProtectiveStopFunctions[0].Name, Is.EqualTo("Door"));
|
|
TaskControlSnapshot task = snapshot.TaskControls[0];
|
|
Assert.That(task.ExecutionMode.WrappedValue.TryGetValue(out int execution), Is.True);
|
|
Assert.That(execution, Is.EqualTo((int)ExecutionModeEnumeration.CONTINUOUS));
|
|
Assert.That(task.TaskProgramLoaded.WrappedValue.TryGetValue(out bool _), Is.True);
|
|
Assert.That(task.TaskProgramName.StatusCode, Is.EqualTo((StatusCode)StatusCodes.Good));
|
|
Assert.That(snapshot.TaskModules.ToArray()!.Select(m => m.Name), Is.EquivalentTo(s_modules));
|
|
Assert.That(snapshot.TaskModules.ToArray()!.Single(m => m.Name == "Main").Version, Is.EqualTo("1.4"));
|
|
|
|
// Motors and gears are placeholders: one of each per power train.
|
|
Assert.That(
|
|
snapshot.PowerTrains.ToArray()!.All(p => p.MotorIds.Count == 1 && p.GearIds.Count == 1),
|
|
Is.True);
|
|
Assert.That(snapshot.Motors, Has.Count.EqualTo(6));
|
|
Assert.That(snapshot.Gears, Has.Count.EqualTo(6));
|
|
Assert.That(
|
|
snapshot.Motors.ToArray()!.All(m => m.Identification.Manufacturer.Text == "Acme Robotics"),
|
|
Is.True);
|
|
Assert.That(snapshot.Drives, Has.Count.EqualTo(1), "one drive drives the A1 and A2 motors");
|
|
Assert.That(snapshot.Drives[0].Identification.ProductCode, Is.EqualTo("DR-1"));
|
|
|
|
// Relationships are browsed in both directions: each Moves reference is
|
|
// listed from its power train and, inverse, from its axis.
|
|
Assert.That(snapshot.Relationships.Moves.ToArray()!.Count(r => !r.IsInverse), Is.EqualTo(6));
|
|
Assert.That(snapshot.Relationships.IsDrivenBy, Is.Not.Empty);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ObserveAxisDeliversChangingPositionsAsync()
|
|
{
|
|
NodeId axisId = await AxisIdAsync("A1").ConfigureAwait(false);
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
|
using var pump = new CancellationTokenSource();
|
|
Task writer = Task.Run(async () =>
|
|
{
|
|
double value = CurrentA1Position();
|
|
while (!pump.IsCancellationRequested)
|
|
{
|
|
value++;
|
|
Write(m_a1Position, value);
|
|
await Task.Delay(150, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
});
|
|
var positions = new List<double>();
|
|
try
|
|
{
|
|
await foreach (AxisStateSnapshot state in m_client.ObserveAxisAsync(
|
|
axisId, m_streaming, timeout.Token).ConfigureAwait(false))
|
|
{
|
|
Assert.That(StatusCode.IsGood(state.ActualPosition.StatusCode), Is.True);
|
|
if (state.ActualPosition.WrappedValue.TryGetValue(out double position) &&
|
|
(positions.Count == 0 || positions[positions.Count - 1] != position))
|
|
{
|
|
positions.Add(position);
|
|
}
|
|
if (positions.Count >= 3)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
pump.Cancel();
|
|
await writer.ConfigureAwait(false);
|
|
}
|
|
|
|
Assert.That(positions, Has.Count.GreaterThanOrEqualTo(3));
|
|
Assert.That(positions, Is.Ordered.Ascending);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ResetToProgramStartRunsTheServerMethodAsync()
|
|
{
|
|
TaskControlClient task = m_client.TaskControl(m_taskControlId);
|
|
if (await task.ReadStateAsync().ConfigureAwait(false) == RoboticsOperationState.Ready)
|
|
{
|
|
await task.UnloadProgramAsync().ConfigureAwait(false);
|
|
}
|
|
await task.LoadByNameAsync("Main").ConfigureAwait(false);
|
|
Assert.That(await task.ReadStateAsync().ConfigureAwait(false), Is.EqualTo(RoboticsOperationState.Ready));
|
|
|
|
int resets = Volatile.Read(ref m_resets);
|
|
await task.ResetToProgramStartAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(Volatile.Read(ref m_resets), Is.EqualTo(resets + 1), "the server's ResetToProgramStart ran");
|
|
await task.UnloadProgramAsync().ConfigureAwait(false);
|
|
Assert.That(await task.ReadStateAsync().ConfigureAwait(false), Is.EqualTo(RoboticsOperationState.Idle));
|
|
}
|
|
|
|
[Test]
|
|
public async Task SnapshotsCarryTheNodeIdsOfTheirValuesAsync()
|
|
{
|
|
RoboticsTopologySnapshot snapshot = await m_client.ReadSystemAsync(
|
|
await SystemIdAsync().ConfigureAwait(false)).ConfigureAwait(false);
|
|
|
|
// The id is the server's ParameterSet variable, not a node of the same
|
|
// name elsewhere below the axis.
|
|
AxisSnapshot a1 = snapshot.Axes.ToArray()!.Single(a => a.Identification.BrowseName.Name == "A1");
|
|
Assert.That(a1.State.ActualPositionId, Is.EqualTo(m_a1Position.NodeId));
|
|
foreach (AxisSnapshot axis in snapshot.Axes.ToArray()!)
|
|
{
|
|
NodeId[] ids = [axis.State.ActualPositionId, axis.State.ActualSpeedId, axis.State.ActualAccelerationId];
|
|
Assert.That(ids.All(id => !id.IsNull), Is.True, axis.Identification.BrowseName.Name);
|
|
Assert.That(ids.Distinct().Count(), Is.EqualTo(3), axis.Identification.BrowseName.Name);
|
|
}
|
|
MotionDeviceSnapshot robot = snapshot.MotionDevices[0];
|
|
Assert.That(robot.SpeedOverrideId.IsNull, Is.False);
|
|
LoadSnapshot flange = snapshot.Loads.ToArray()!.Single(l => l.NodeId == robot.FlangeLoadId);
|
|
Assert.That(flange.MassId.IsNull, Is.False);
|
|
SafetyStateSnapshot safety = snapshot.SafetyStates[0];
|
|
Assert.That(safety.EmergencyStopId.IsNull, Is.False);
|
|
Assert.That(safety.OperationalModeId.IsNull, Is.False);
|
|
Assert.That(safety.ProtectiveStopId.IsNull, Is.False);
|
|
SafetyFunctionSnapshot door = safety.ProtectiveStopFunctions[0];
|
|
Assert.That(door.ActiveId.IsNull, Is.False);
|
|
Assert.That(door.EnabledId.IsNull, Is.False);
|
|
TaskControlSnapshot task = snapshot.TaskControls[0];
|
|
Assert.That(task.ExecutionModeId.IsNull, Is.False);
|
|
Assert.That(task.TaskProgramLoadedId.IsNull, Is.False);
|
|
Assert.That(task.TaskProgramNameId.IsNull, Is.False);
|
|
// Diagnostics is built without the optional IsReferenced: no node, no id.
|
|
TaskModuleSnapshot[] modules = snapshot.TaskModules.ToArray()!;
|
|
Assert.That(modules.Where(m => m.Name != "Diagnostics").All(m => !m.IsReferencedId.IsNull), Is.True);
|
|
Assert.That(modules.Single(m => m.Name == "Diagnostics").IsReferencedId.IsNull, Is.True);
|
|
Assert.That(snapshot.Gears.ToArray()!.All(g => !g.PitchId.IsNull), Is.True);
|
|
Assert.That(snapshot.Gears[0].Pitch.WrappedValue.TryGetValue(out double pitch), Is.True);
|
|
Assert.That(pitch, Is.EqualTo(5.0));
|
|
|
|
// Each id is the variable itself: reading it returns the snapshot value.
|
|
NodeId[] nodes =
|
|
[
|
|
robot.SpeedOverrideId,
|
|
flange.MassId,
|
|
safety.OperationalModeId,
|
|
task.ExecutionModeId,
|
|
snapshot.Gears[0].PitchId
|
|
];
|
|
Variant[] expected =
|
|
[
|
|
robot.SpeedOverride.WrappedValue,
|
|
flange.Mass.WrappedValue,
|
|
safety.OperationalMode.WrappedValue,
|
|
task.ExecutionMode.WrappedValue,
|
|
snapshot.Gears[0].Pitch.WrappedValue
|
|
];
|
|
for (int ii = 0; ii < nodes.Length; ii++)
|
|
{
|
|
DataValue value = await m_session.ReadValueAsync(nodes[ii]).ConfigureAwait(false);
|
|
Assert.That(value.WrappedValue, Is.EqualTo(expected[ii]), nodes[ii].ToString());
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task ASubscriptionOnTheActualPositionIdDeliversChangingValuesAsync()
|
|
{
|
|
AxisSnapshot a1 = await m_client.ReadAxisAsync(await AxisIdAsync("A1").ConfigureAwait(false))
|
|
.ConfigureAwait(false);
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
|
using var pump = new CancellationTokenSource();
|
|
var writer = Task.Run(async () =>
|
|
{
|
|
double value = CurrentA1Position();
|
|
while (!pump.IsCancellationRequested)
|
|
{
|
|
value++;
|
|
Write(m_a1Position, value);
|
|
await Task.Delay(150, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
});
|
|
var positions = new List<double>();
|
|
try
|
|
{
|
|
await foreach (DataValueChange change in m_streaming.SubscribeDataChangesAsync(
|
|
a1.State.ActualPositionId, null, timeout.Token).ConfigureAwait(false))
|
|
{
|
|
if (change.Value.WrappedValue.TryGetValue(out double position) &&
|
|
(positions.Count == 0 || positions[^1] != position))
|
|
{
|
|
positions.Add(position);
|
|
}
|
|
if (positions.Count >= 3)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
pump.Cancel();
|
|
await writer.ConfigureAwait(false);
|
|
}
|
|
|
|
Assert.That(positions, Has.Count.GreaterThanOrEqualTo(3));
|
|
Assert.That(positions, Is.Ordered.Ascending);
|
|
}
|
|
|
|
[Test]
|
|
public async Task SnapshotsReportTheCurrentStateOfTheOperationsAsync()
|
|
{
|
|
// The server starts the SystemOperation in Ready, not in the default Idle.
|
|
ControllerSnapshot controller = await m_client.ReadControllerAsync(m_controllerId).ConfigureAwait(false);
|
|
Assert.That(controller.SystemOperationId, Is.EqualTo(m_systemOperationId));
|
|
Assert.That(controller.CurrentStateId, Is.EqualTo(m_systemCurrentStateId));
|
|
Assert.That(controller.CurrentState, Is.EqualTo(RoboticsOperationState.Ready));
|
|
|
|
TaskControlClient task = m_client.TaskControl(m_taskControlId);
|
|
if (await task.ReadStateAsync().ConfigureAwait(false) == RoboticsOperationState.Ready)
|
|
{
|
|
await task.UnloadProgramAsync().ConfigureAwait(false);
|
|
}
|
|
TaskControlSnapshot idle = await m_client.ReadTaskControlAsync(m_taskControlId).ConfigureAwait(false);
|
|
Assert.That(idle.CurrentStateId, Is.EqualTo(m_taskCurrentStateId));
|
|
Assert.That(idle.CurrentState, Is.EqualTo(RoboticsOperationState.Idle));
|
|
|
|
await task.LoadByNameAsync("Main").ConfigureAwait(false);
|
|
TaskControlSnapshot ready = await m_client.ReadTaskControlAsync(m_taskControlId).ConfigureAwait(false);
|
|
Assert.That(ready.CurrentStateId, Is.EqualTo(m_taskCurrentStateId), "the CurrentState node stays the same");
|
|
Assert.That(ready.CurrentState, Is.EqualTo(RoboticsOperationState.Ready));
|
|
DataValue current = await m_session.ReadValueAsync(ready.CurrentStateId).ConfigureAwait(false);
|
|
Assert.That(current.WrappedValue.TryGetValue(out LocalizedText state), Is.True);
|
|
Assert.That(state.Text, Is.EqualTo(BrowseNames.Ready));
|
|
|
|
await task.UnloadProgramAsync().ConfigureAwait(false);
|
|
TaskControlSnapshot unloaded = await m_client.ReadTaskControlAsync(m_taskControlId).ConfigureAwait(false);
|
|
Assert.That(unloaded.CurrentState, Is.EqualTo(RoboticsOperationState.Idle));
|
|
}
|
|
|
|
[Test]
|
|
public async Task IdentificationAndMotorValuesAreReadAsync()
|
|
{
|
|
RoboticsTopologySnapshot snapshot = await m_client.ReadSystemAsync(
|
|
await SystemIdAsync().ConfigureAwait(false)).ConfigureAwait(false);
|
|
|
|
RoboticsComponentIdentification robot = snapshot.MotionDevices[0].Identification;
|
|
Assert.That(robot.HardwareRevision, Is.EqualTo("HW-2"));
|
|
Assert.That(robot.SoftwareRevision, Is.EqualTo("7.3.1"));
|
|
Assert.That(robot.ManufacturerUri, Is.EqualTo("https://acme-robotics.example"));
|
|
Assert.That(robot.ProductInstanceUri, Is.EqualTo("urn:acme-robotics:AR6-0001"));
|
|
Assert.That(
|
|
snapshot.Controllers[0].Identification.ProductInstanceUri,
|
|
Is.EqualTo("urn:acme-robotics:RC9-0001"));
|
|
// The drive is built with a product code only.
|
|
Assert.That(snapshot.Drives[0].Identification.ProductCode, Is.EqualTo("DR-1"));
|
|
Assert.That(snapshot.Drives[0].Identification.HardwareRevision, Is.Null);
|
|
|
|
Assert.That(snapshot.Motors, Has.Count.EqualTo(6));
|
|
foreach (MotorSnapshot motor in snapshot.Motors.ToArray()!)
|
|
{
|
|
Assert.That(motor.Identification.SoftwareRevision, Is.EqualTo("7.3.1"));
|
|
Assert.That(motor.MotorTemperature.WrappedValue.TryGetValue(out double temperature), Is.True);
|
|
Assert.That(temperature, Is.EqualTo(38.5));
|
|
Assert.That(motor.MotorTemperatureId.IsNull, Is.False);
|
|
Assert.That(motor.MotorTemperatureEngineering.EngineeringUnits?.DisplayName.Text, Is.EqualTo("°C"));
|
|
Assert.That(motor.MotorTemperatureEngineering.Range?.Low, Is.EqualTo(-20));
|
|
Assert.That(motor.MotorTemperatureEngineering.Range?.High, Is.EqualTo(120));
|
|
Assert.That(motor.BrakeReleased.WrappedValue.TryGetValue(out bool released), Is.True);
|
|
Assert.That(released, Is.True);
|
|
Assert.That(motor.BrakeReleasedId.IsNull, Is.False);
|
|
Assert.That(motor.EffectiveLoadRate.WrappedValue.TryGetValue(out ushort rate), Is.True);
|
|
Assert.That(rate, Is.EqualTo(40));
|
|
Assert.That(motor.EffectiveLoadRateId.IsNull, Is.False);
|
|
}
|
|
MotorSnapshot motorA1 = snapshot.Motors.ToArray()!
|
|
.Single(m => m.Identification.BrowseName.Name == "M-A1");
|
|
Assert.That(motorA1.MotorTemperatureId, Is.EqualTo(m_motorA1Temperature.NodeId));
|
|
}
|
|
|
|
[Test]
|
|
public async Task EngineeringValuesAndGearRatiosAreReadAsync()
|
|
{
|
|
RoboticsTopologySnapshot snapshot = await m_client.ReadSystemAsync(
|
|
await SystemIdAsync().ConfigureAwait(false)).ConfigureAwait(false);
|
|
|
|
Assert.That(snapshot.Axes, Has.Count.EqualTo(6));
|
|
foreach (AxisSnapshot axis in snapshot.Axes.ToArray()!)
|
|
{
|
|
string? name = axis.Identification.BrowseName.Name;
|
|
AxisEngineeringOptions engineering = axis.Engineering;
|
|
Assert.That(engineering.PositionUnit?.DisplayName.Text, Is.EqualTo("°"), name);
|
|
Assert.That(engineering.Limits.Position?.Low, Is.EqualTo(-180), name);
|
|
Assert.That(engineering.Limits.Position?.High, Is.EqualTo(180), name);
|
|
Assert.That(engineering.SpeedUnit?.DisplayName.Text, Is.EqualTo("°/s"), name);
|
|
Assert.That(engineering.Limits.Speed?.Low, Is.EqualTo(-250), name);
|
|
Assert.That(engineering.Limits.Speed?.High, Is.EqualTo(250), name);
|
|
Assert.That(engineering.AccelerationUnit?.DisplayName.Text, Is.EqualTo("°/s²"), name);
|
|
Assert.That(engineering.Limits.Acceleration, Is.Null, name);
|
|
}
|
|
|
|
MotionDeviceSnapshot robot = snapshot.MotionDevices[0];
|
|
LoadSnapshot flange = snapshot.Loads.ToArray()!.Single(l => l.NodeId == robot.FlangeLoadId);
|
|
Assert.That(flange.MassEngineering.EngineeringUnits?.DisplayName.Text, Is.EqualTo("kg"));
|
|
Assert.That(flange.MassEngineering.Range?.Low, Is.Zero);
|
|
Assert.That(flange.MassEngineering.Range?.High, Is.EqualTo(20));
|
|
NodeId toolId = snapshot.Axes.ToArray()!
|
|
.Single(a => a.Identification.BrowseName.Name == "A6").AdditionalLoadId;
|
|
LoadSnapshot tool = snapshot.Loads.ToArray()!.Single(l => l.NodeId == toolId);
|
|
Assert.That(tool.Mass.WrappedValue.TryGetValue(out double toolMass), Is.True);
|
|
Assert.That(toolMass, Is.EqualTo(2.0));
|
|
// EngineeringUnits is mandatory and always published, EURange is optional.
|
|
Assert.That(tool.MassEngineering.EngineeringUnits?.DisplayName.Text, Is.Not.EqualTo("kg"));
|
|
Assert.That(tool.MassEngineering.Range, Is.Null, "the tool load's Mass has no EURange");
|
|
|
|
Assert.That(snapshot.Gears, Has.Count.EqualTo(6));
|
|
foreach (GearSnapshot gear in snapshot.Gears.ToArray()!)
|
|
{
|
|
Assert.That(gear.GearRatio?.Numerator, Is.EqualTo(160), gear.Identification.BrowseName.Name);
|
|
Assert.That(gear.GearRatio?.Denominator, Is.EqualTo(1u), gear.Identification.BrowseName.Name);
|
|
}
|
|
|
|
// The per-node reader returns the same engineering values.
|
|
AxisSnapshot a1 = await m_client.ReadAxisAsync(await AxisIdAsync("A1").ConfigureAwait(false))
|
|
.ConfigureAwait(false);
|
|
Assert.That(a1.Engineering.PositionUnit?.UnitId, Is.EqualTo(Unit("DD", "°", "degree").UnitId));
|
|
Assert.That(a1.Engineering.Limits.Speed?.High, Is.EqualTo(250));
|
|
}
|
|
|
|
private async Task<NodeId> SystemIdAsync()
|
|
{
|
|
await foreach (MotionDeviceSystemEntry entry in m_client.EnumerateMotionDeviceSystemsAsync()
|
|
.ConfigureAwait(false))
|
|
{
|
|
return entry.NodeId;
|
|
}
|
|
throw new AssertionException("The server publishes no MotionDeviceSystem.");
|
|
}
|
|
|
|
private async Task<NodeId> AxisIdAsync(string name)
|
|
{
|
|
RoboticsTopologySnapshot snapshot = await m_client.ReadSystemAsync(
|
|
await SystemIdAsync().ConfigureAwait(false)).ConfigureAwait(false);
|
|
return snapshot.Axes.ToArray()!
|
|
.Single(a => a.Identification.BrowseName.Name == name).Identification.NodeId;
|
|
}
|
|
|
|
private async ValueTask ConfigureCellAsync(IRoboticsBuildContext context, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
m_serverContext = context.Context;
|
|
ITaskControlBuilder? mainTask = null;
|
|
IControllerBuilder? controllerBuilder = null;
|
|
ISystemOperationBuilder? systemOperation = null;
|
|
await context.AddMotionDeviceSystemAsync(
|
|
"Cell",
|
|
system =>
|
|
{
|
|
system.WithComponentName("Cell");
|
|
IMotionDeviceBuilder robot = system.AddMotionDevice("Robot", BuildRobot);
|
|
ISafetyStateBuilder safety = system.AddSafetyState("Safety", state =>
|
|
{
|
|
state.WithComponentName("Cell safety")
|
|
.WithEmergencyStop(false)
|
|
.WithProtectiveStop(false)
|
|
.WithOperationalMode(OperationalModeEnumeration.AUTOMATIC);
|
|
state.AddEmergencyStop("PendantStop", "Pendant", stop => stop.WithActive(false));
|
|
state.AddProtectiveStop(
|
|
"DoorStop",
|
|
"Door",
|
|
stop => stop.WithEnabled(true).WithActive(false));
|
|
});
|
|
system.AddController("Controller", controller =>
|
|
{
|
|
controllerBuilder = controller;
|
|
controller.WithComponentName("Controller")
|
|
.WithIdentification(Identity("RC-9", "RC9", "RC9-0001"));
|
|
controller.AddSoftware("RobotOS", software => software
|
|
.WithIdentification(Identity("RobotOS", "ROS-7", "ROS7-0001")));
|
|
// Not the default Idle, so a snapshot that reads no state fails.
|
|
systemOperation = controller.AddSystemOperation(operation => operation
|
|
.WithInitialState(RoboticsOperationState.Ready));
|
|
mainTask = controller.AddTaskControl("MainTask", task =>
|
|
{
|
|
task.WithComponentName("Main task")
|
|
.WithExecutionMode(ExecutionModeEnumeration.CONTINUOUS)
|
|
.WithTaskProgramLoaded(false)
|
|
.WithTaskProgramName(string.Empty)
|
|
.Controls(robot);
|
|
task.AddTaskModule("Main", m => m
|
|
.WithName("Main")
|
|
.WithVersion("1.4")
|
|
.WithIsReferenced(true));
|
|
task.AddTaskModule("Gripper", m => m
|
|
.WithName("Gripper")
|
|
.WithVersion("2.0")
|
|
.WithIsReferenced(true));
|
|
task.AddTaskModule("Diagnostics", m => m
|
|
.WithName("Diagnostics")
|
|
.WithVersion("1.1"));
|
|
task.AddTaskControlOperation(operation => operation
|
|
.WithMotionDevicesUnderControl([robot.State.NodeId])
|
|
.OnLoadByName((_, _) =>
|
|
new ValueTask<RoboticsProgramResult>(new RoboticsProgramResult()))
|
|
.OnUnloadProgram(_ =>
|
|
new ValueTask<RoboticsProgramResult>(new RoboticsProgramResult()))
|
|
.OnResetToProgramStart(_ =>
|
|
{
|
|
Interlocked.Increment(ref m_resets);
|
|
return new ValueTask<RoboticsProgramResult>(new RoboticsProgramResult());
|
|
}));
|
|
});
|
|
controller.Controls(robot).UsesSafetyState(safety);
|
|
});
|
|
},
|
|
cancellationToken).ConfigureAwait(false);
|
|
m_taskControlId = mainTask!.State.NodeId;
|
|
m_taskCurrentStateId = mainTask.State.TaskControlOperation!
|
|
.TaskControlStateMachine!.CurrentState!.NodeId;
|
|
m_controllerId = controllerBuilder!.State.NodeId;
|
|
m_systemOperationId = systemOperation!.State.NodeId;
|
|
m_systemCurrentStateId = systemOperation.State.SystemOperationStateMachine!.CurrentState!.NodeId;
|
|
m_cellReady.TrySetResult(true);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
m_cellReady.TrySetException(exception);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private void BuildRobot(IMotionDeviceBuilder robot)
|
|
{
|
|
EUInformation degree = Unit("DD", "°", "degree");
|
|
EUInformation degreePerSecond = Unit("E96", "°/s", "degree per second");
|
|
EUInformation degreePerSecondSquared = Unit("M45", "°/s²", "degree per second squared");
|
|
robot.WithComponentName("AR-6")
|
|
.WithIdentification(Identity("AR-6", "AR6", "AR6-0001"))
|
|
.WithCategory(MotionDeviceCategoryEnumeration.ARTICULATED_ROBOT)
|
|
.WithSpeedOverride(100.0)
|
|
.WithFlangeLoad(load => load.WithMass(6.5, Unit("KGM", "kg", "kilogram"), new Range(20, 0)));
|
|
IDriveBuilder drive = robot.AddDrive("DriveA1", d => d.WithProductCode("DR-1"));
|
|
for (int ii = 0; ii < s_axes.Length; ii++)
|
|
{
|
|
int index = ii;
|
|
string name = s_axes[ii];
|
|
IAxisBuilder axis = robot.AddAxis(name, a =>
|
|
{
|
|
// Every value with its unit; no EURange for ActualAcceleration.
|
|
a.WithMotionProfile(AxisMotionProfileEnumeration.ROTARY)
|
|
.WithActualPosition(s_home[index], degree, new Range(180, -180))
|
|
.WithActualSpeed(0, degreePerSecond, new Range(250, -250))
|
|
.WithActualAcceleration(0, degreePerSecondSquared)
|
|
.Configure((node, context) =>
|
|
{
|
|
if (index == 0)
|
|
{
|
|
m_a1Position = Child(node.ParameterSet!, context, BrowseNames.ActualPosition);
|
|
}
|
|
});
|
|
if (index == s_axes.Length - 1)
|
|
{
|
|
// A tool load with a mass only: no unit, no range.
|
|
a.WithAdditionalLoad(load => load.WithMass(2.0));
|
|
}
|
|
});
|
|
IPowerTrainBuilder powerTrain = robot.AddPowerTrain($"PT-{name}", pt =>
|
|
{
|
|
IMotorBuilder motor = pt.AddMotor($"M-{name}", m => m
|
|
.WithIdentification(Identity("SM-80", "SM80", $"M-{name}"))
|
|
.WithMotorTemperature(38.5, Unit("CEL", "°C", "degree Celsius"), new Range(120, -20))
|
|
.WithBrakeReleased(true)
|
|
.WithEffectiveLoadRate(40)
|
|
.Configure((node, context) =>
|
|
{
|
|
if (index == 0)
|
|
{
|
|
m_motorA1Temperature = Child(node.ParameterSet!, context, BrowseNames.MotorTemperature);
|
|
}
|
|
}));
|
|
// One drive powers the first two motors; the snapshot lists it once.
|
|
if (index < 2)
|
|
{
|
|
motor.IsDrivenBy(drive);
|
|
}
|
|
pt.AddGear($"G-{name}", g => g
|
|
.WithIdentification(Identity("HD-40", "HD40", $"G-{name}"))
|
|
.WithGearRatio(160, 1)
|
|
.WithPitch(5.0));
|
|
});
|
|
powerTrain.Moves(axis);
|
|
axis.Requires(powerTrain);
|
|
}
|
|
}
|
|
|
|
private async Task WaitForCellAsync()
|
|
{
|
|
// The hosted service opens the server and runs the configurator in the
|
|
// background; a failure there surfaces as a faulted execute task.
|
|
DateTime deadline = DateTime.UtcNow.AddSeconds(60);
|
|
while (!m_cellReady.Task.IsCompleted && DateTime.UtcNow < deadline)
|
|
{
|
|
foreach (IHostedService service in m_hostedServices)
|
|
{
|
|
if (service is BackgroundService { ExecuteTask: { IsFaulted: true } failed })
|
|
{
|
|
throw new InvalidOperationException("The hosted server failed to start.", failed.Exception);
|
|
}
|
|
}
|
|
await Task.Delay(100).ConfigureAwait(false);
|
|
}
|
|
Assert.That(m_cellReady.Task.IsCompleted, Is.True, "the Robotics configurator ran");
|
|
await m_cellReady.Task.ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task<EndpointDescription> WaitForEndpointAsync()
|
|
{
|
|
Exception? lastException = null;
|
|
DateTime deadline = DateTime.UtcNow.AddSeconds(30);
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
try
|
|
{
|
|
EndpointDescription? endpoint = await CoreClientUtils.SelectEndpointAsync(
|
|
m_clientConfiguration,
|
|
m_serverUrl,
|
|
useSecurity: false,
|
|
Telemetry,
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
if (endpoint != null)
|
|
{
|
|
return endpoint;
|
|
}
|
|
}
|
|
catch (ServiceResultException exception)
|
|
{
|
|
lastException = exception;
|
|
}
|
|
await Task.Delay(100).ConfigureAwait(false);
|
|
}
|
|
throw new TimeoutException($"'{m_serverUrl}' did not become available: {lastException?.Message}");
|
|
}
|
|
|
|
private void ConfigureServer(OpcUaServerOptions options)
|
|
{
|
|
string root = Path.Combine(
|
|
TestContext.CurrentContext.WorkDirectory,
|
|
nameof(RoboticsClientServerE2eTests),
|
|
Guid.NewGuid().ToString("N"));
|
|
options.ApplicationName = "RoboticsE2eServer";
|
|
options.ApplicationUri = "urn:localhost:OPCFoundation:RoboticsE2eServer";
|
|
options.ProductUri = "uri:opcfoundation.org:RoboticsE2eServer";
|
|
options.PkiRoot = Path.Combine(root, "pki");
|
|
options.AutoAcceptUntrustedCertificates = true;
|
|
options.IncludeUnsecurePolicyNone = true;
|
|
options.EndpointUrls.Clear();
|
|
options.EndpointUrls.Add(m_serverUrl);
|
|
options.UserTokenPolicies.Add(new OpcUaUserTokenPolicy { TokenType = UserTokenType.Anonymous });
|
|
}
|
|
|
|
private async ValueTask<ApplicationConfiguration> CreateClientConfigurationAsync()
|
|
{
|
|
string pkiRoot = Path.Combine(
|
|
TestContext.CurrentContext.WorkDirectory,
|
|
nameof(RoboticsClientServerE2eTests),
|
|
Guid.NewGuid().ToString("N"),
|
|
"client-pki");
|
|
var configuration = new ApplicationConfiguration(Telemetry)
|
|
{
|
|
ApplicationName = "RoboticsE2eClient",
|
|
ApplicationUri = "urn:localhost:OPCFoundation:RoboticsE2eClient",
|
|
ApplicationType = ApplicationType.Client,
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(pkiRoot, "own"),
|
|
SubjectName = "CN=RoboticsE2eClient, O=OPC Foundation"
|
|
},
|
|
TrustedIssuerCertificates = Store(Path.Combine(pkiRoot, "issuer")),
|
|
TrustedPeerCertificates = Store(Path.Combine(pkiRoot, "trusted")),
|
|
RejectedCertificateStore = Store(Path.Combine(pkiRoot, "rejected")),
|
|
AutoAcceptUntrustedCertificates = true
|
|
},
|
|
TransportQuotas = new TransportQuotas { MaxMessageSize = 4 * 1024 * 1024 },
|
|
ClientConfiguration = new ClientConfiguration(),
|
|
ServerConfiguration = new ServerConfiguration()
|
|
};
|
|
await configuration.ValidateAsync(ApplicationType.Client).ConfigureAwait(false);
|
|
var application = new ApplicationInstance(configuration, Telemetry);
|
|
await application.CheckApplicationInstanceCertificatesAsync(true).ConfigureAwait(false);
|
|
configuration.CertificateManager ??= CertificateManagerFactory.Create(
|
|
configuration.SecurityConfiguration,
|
|
Telemetry);
|
|
configuration.CertificateManager.AcceptError = static (_, _) => true;
|
|
return configuration;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The A1 position as the server holds it now. The writers of the position tests
|
|
/// count up from here, so the order of the tests cannot reset the position and
|
|
/// break the ascending assertions.
|
|
/// </summary>
|
|
private double CurrentA1Position()
|
|
{
|
|
return m_a1Position.WrappedValue.TryGetValue(out double position) ? position : 0;
|
|
}
|
|
|
|
private void Write(BaseDataVariableState node, double value)
|
|
{
|
|
node.WrappedValue = Variant.From(value);
|
|
node.Timestamp = DateTime.UtcNow;
|
|
node.ClearChangeMasks(m_serverContext, includeChildren: false);
|
|
}
|
|
|
|
private static Action<DeviceIdentificationData> Identity(string model, string productCode, string serial)
|
|
{
|
|
return data =>
|
|
{
|
|
data.Manufacturer = new LocalizedText("en", "Acme Robotics");
|
|
data.Model = new LocalizedText("en", model);
|
|
data.ProductCode = productCode;
|
|
data.SerialNumber = serial;
|
|
data.HardwareRevision = "HW-2";
|
|
data.SoftwareRevision = "7.3.1";
|
|
data.ManufacturerUri = "https://acme-robotics.example";
|
|
data.ProductInstanceUri = $"urn:acme-robotics:{serial}";
|
|
};
|
|
}
|
|
|
|
private static BaseDataVariableState Child(NodeState parent, ISystemContext context, string browseName)
|
|
{
|
|
var children = new List<BaseInstanceState>();
|
|
parent.GetChildren(context, children);
|
|
return children.OfType<BaseDataVariableState>().Single(c => c.BrowseName.Name == browseName);
|
|
}
|
|
|
|
private static EUInformation Unit(string commonCode, string symbol, string name)
|
|
{
|
|
int unitId = 0;
|
|
foreach (char c in commonCode)
|
|
{
|
|
unitId = (unitId << 8) | c;
|
|
}
|
|
return new EUInformation
|
|
{
|
|
NamespaceUri = "http://www.opcfoundation.org/UA/units/un/cefact",
|
|
UnitId = unitId,
|
|
DisplayName = new LocalizedText("en", symbol),
|
|
Description = new LocalizedText("en", name)
|
|
};
|
|
}
|
|
|
|
private static CertificateTrustList Store(string path)
|
|
{
|
|
return new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = path };
|
|
}
|
|
|
|
private static int GetFreeTcpPort()
|
|
{
|
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
try
|
|
{
|
|
return ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
}
|
|
finally
|
|
{
|
|
listener.Stop();
|
|
}
|
|
}
|
|
|
|
private static readonly string[] s_axes = ["A1", "A2", "A3", "A4", "A5", "A6"];
|
|
private static readonly double[] s_home = [0.0, -60.0, 75.0, 0.0, 45.0, 0.0];
|
|
private static readonly string[] s_modules = ["Main", "Gripper", "Diagnostics"];
|
|
|
|
private readonly TaskCompletionSource<bool> m_cellReady =
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
private ServiceProvider? m_provider;
|
|
private List<IHostedService> m_hostedServices = [];
|
|
private ApplicationConfiguration m_clientConfiguration = null!;
|
|
private ISession m_session = null!;
|
|
private StreamingSubscription m_streaming = null!;
|
|
private RoboticsClient m_client = null!;
|
|
private ISystemContext m_serverContext = null!;
|
|
private BaseDataVariableState m_a1Position = null!;
|
|
private BaseDataVariableState m_motorA1Temperature = null!;
|
|
private NodeId m_taskControlId;
|
|
private NodeId m_taskCurrentStateId;
|
|
private NodeId m_controllerId;
|
|
private NodeId m_systemOperationId;
|
|
private NodeId m_systemCurrentStateId;
|
|
private int m_resets;
|
|
private string m_serverUrl = string.Empty;
|
|
}
|
|
}
|