Files
UA-.NETStandard/tests/Opc.Ua.Positioning.Tests/PositioningProviderTests.cs
Marc Schier c48c8b93dd Positioning: OPC 10000-210 RSL and 10000-211 GPOS on one geolocation provider (#4125)
**Stack 3/5** — base #4124. Part of the #4096 split; see #4096 for the
overall picture.

> Review after #4123 and #4124.

## What this is

The OPC 10000-210 Relative Spatial Location and OPC 10000-211 Global
Positioning companion models, with their server and client libraries,
plus the ISA-95 migration onto the shared geolocation seam.

## Why it is one change

A deployment writes **one** provider implementation and both OPC
10000-211 and OPC 10030 are served from it. The specifications disagree
on representation, not on content:

- GPOS publishes the structured `GlobalLocationDataType` and validates
the EPSG code against the configured CRS.
- ISA-95 publishes the same sample as the `String` array that OPC 10030
§8.3.2.2 requires.

Splitting them would mean two provider interfaces for one physical
sensor, which is exactly what this avoids. The seam itself lives in
`Opc.Ua.Server` and landed in 1/5.

## Contents

- `Opc.Ua.Positioning`, `.Server` and `.Client`.
- `Opc.Ua.ISA95.Server` moves onto `IGeoLocationProvider`; the three
ISA-95-specific provider files are removed because the shared seam and
its in-memory implementation replace them.
- **Fixes a conformance gap**: the ISA-95 binder wrote a scalar where
the type declares `ValueRank` OneOrMoreDimensions. It now writes
`String[]`.
- **Fixes a liveness bug**: a provider that cannot push is polled at the
Variable's `MinimumSamplingInterval` rather than installed once, so a
non-pushing provider no longer serves its start-up sample with `Good`
status forever.
- `samples/MinimalIsa95Server`.

## Validation

`dotnet build UA.slnx` — 0 errors.

| Suite | Result |
| --- | --- |
| Opc.Ua.Positioning.Tests | 69 / 0 |
| Opc.Ua.ISA95.Tests | 136 / 0 |
| Opc.Ua.Di.Tests | 304 / 0 |

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8cbb8cd0-f0cb-4ab0-bea2-6202fbf69485
2026-08-01 07:55:58 +02:00

541 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.Concurrent;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.Gpos;
using Opc.Ua.Positioning.Server;
using Opc.Ua.Rsl;
namespace Opc.Ua.Positioning.Tests
{
[TestFixture]
[Category("Positioning")]
[NonParallelizable]
public sealed class PositioningProviderTests
{
private PositioningServerFixture? m_fixture;
private PositioningAddressSpaceBuilder m_builder = null!;
private BaseObjectState m_owner = null!;
private ushort m_namespaceIndex;
[SetUp]
public async Task SetUpAsync()
{
m_fixture = new PositioningServerFixture();
await m_fixture.StartAsync().ConfigureAwait(false);
m_builder = m_fixture.Manager.CreatePositioningBuilder();
m_namespaceIndex = (ushort)m_fixture.Manager.Server.NamespaceUris
.GetIndex(Rsl.Namespaces.RSL);
m_owner = new BaseObjectState(null)
{
NodeId = new NodeId("ProviderOwner", m_namespaceIndex),
BrowseName = new QualifiedName("ProviderOwner", m_namespaceIndex),
DisplayName = new LocalizedText("ProviderOwner"),
TypeDefinitionId = ObjectTypeIds.BaseObjectType
};
await m_fixture.Manager.AddPredefinedNodeAsync(m_owner)
.ConfigureAwait(false);
}
[TearDown]
public async Task TearDownAsync()
{
if (m_fixture != null)
{
await m_fixture.DisposeAsync().ConfigureAwait(false);
}
}
[Test]
public async Task GlobalProviderAppliesUpdatesAndCancelsAsync()
{
GlobalLocationState state = await CreateGlobalLocationAsync()
.ConfigureAwait(false);
using var provider = new ControlledGlobalProvider(
CreateGlobalSample("robot", 8.0, includeOptionalFields: true));
PositioningProviderSubscription subscription =
await m_builder.BindGlobalLocationAsync(
state,
provider,
"robot").ConfigureAwait(false);
provider.Publish(
CreateGlobalSample("robot", 9.0, includeOptionalFields: false));
await WaitUntilAsync(
() => state.Value.Position.Longitude == 9.0)
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(state.Value.Position.Longitude, Is.EqualTo(9.0));
Assert.That(state.Position!.Longitude!.Value, Is.EqualTo(9.0));
Assert.That(
state.Position.Elevation!.StatusCode,
Is.EqualTo(StatusCodes.BadNoData));
Assert.That(
state.Orientation!.StatusCode,
Is.EqualTo(StatusCodes.BadNoData));
Assert.That(
state.Orientation.A!.StatusCode,
Is.EqualTo(StatusCodes.BadNoData));
Assert.That(
state.Orientation.B!.StatusCode,
Is.EqualTo(StatusCodes.BadNoData));
Assert.That(
state.Orientation.C!.StatusCode,
Is.EqualTo(StatusCodes.BadNoData));
Assert.That(state.Orientation.C!.Value, Is.EqualTo(90.0));
});
await subscription.DisposeAsync().ConfigureAwait(false);
Task completed = await Task.WhenAny(
provider.CancellationObserved,
Task.Delay(TimeSpan.FromSeconds(2))).ConfigureAwait(false);
Assert.That(completed, Is.SameAs(provider.CancellationObserved));
}
[Test]
public async Task GlobalProviderFailurePreservesLastValueAndMarksStatusBadAsync()
{
GlobalLocationState state = await CreateGlobalLocationAsync()
.ConfigureAwait(false);
var provider = new FailingGlobalProvider(
CreateGlobalSample("robot", 8.0, includeOptionalFields: true),
CreateGlobalSample("robot", 9.0, includeOptionalFields: true));
PositioningProviderSubscription subscription =
await m_builder.BindGlobalLocationAsync(
state,
provider,
"robot").ConfigureAwait(false);
InvalidOperationException? failure = null;
try
{
await subscription.Completion.ConfigureAwait(false);
}
catch (InvalidOperationException ex)
{
failure = ex;
}
subscription.Dispose();
Assert.Multiple(() =>
{
Assert.That(failure, Is.Not.Null);
Assert.That(state.Value.Position.Longitude, Is.EqualTo(9.0));
Assert.That(
state.StatusCode,
Is.EqualTo(StatusCodes.BadCommunicationError));
Assert.That(
state.Position!.Longitude!.StatusCode,
Is.EqualTo(StatusCodes.BadCommunicationError));
Assert.That(
state.Orientation!.C!.StatusCode,
Is.EqualTo(StatusCodes.BadCommunicationError));
});
}
[Test]
public async Task ProviderSourceMismatchIsRejectedAndSurfacedAsync()
{
GlobalLocationState state = await CreateGlobalLocationAsync()
.ConfigureAwait(false);
using var provider = new ControlledGlobalProvider(
CreateGlobalSample("other", 8.0, includeOptionalFields: true));
ServiceResultException? failure = null;
try
{
_ = await m_builder.BindGlobalLocationAsync(
state,
provider,
"robot").ConfigureAwait(false);
}
catch (ServiceResultException ex)
{
failure = ex;
}
Assert.Multiple(() =>
{
Assert.That(failure, Is.Not.Null);
Assert.That(
failure!.StatusCode,
Is.EqualTo(StatusCodes.BadInvalidArgument));
Assert.That(
state.StatusCode,
Is.EqualTo(StatusCodes.BadCommunicationError));
});
}
[Test]
public async Task RelativeProviderFailurePreservesFrameAndMarksStatusBadAsync()
{
EUInformation metres = CreateUnit("m", "metre");
EUInformation degrees = CreateUnit("deg", "degree");
CartesianFrameAngleOrientationState frame = m_builder.CreateCartesianFrame(
m_owner,
new QualifiedName("TrackedFrame", m_namespaceIndex),
m_owner.NodeId,
CreateFrame(1.0),
metres,
degrees);
m_owner.AddChild(frame);
await m_builder.RegisterAsync(frame).ConfigureAwait(false);
var provider = new FailingRelativeProvider(
new RelativeSpatialLocationSample(
"robot",
CreateFrame(2.0),
StatusCodes.Good,
DateTimeUtc.Now),
new RelativeSpatialLocationSample(
"robot",
CreateFrame(3.0),
StatusCodes.Good,
DateTimeUtc.Now));
PositioningProviderSubscription subscription =
await m_builder.BindRelativeSpatialLocationAsync(
frame,
provider,
"robot").ConfigureAwait(false);
InvalidOperationException? failure = null;
try
{
await subscription.Completion.ConfigureAwait(false);
}
catch (InvalidOperationException ex)
{
failure = ex;
}
subscription.Dispose();
Assert.Multiple(() =>
{
Assert.That(failure, Is.Not.Null);
Assert.That(frame.Value.CartesianCoordinates.X, Is.EqualTo(3.0));
Assert.That(
frame.StatusCode,
Is.EqualTo(StatusCodes.BadCommunicationError));
Assert.That(
frame.Position!.StatusCode,
Is.EqualTo(StatusCodes.BadCommunicationError));
Assert.That(
frame.Position.X!.StatusCode,
Is.EqualTo(StatusCodes.BadCommunicationError));
Assert.That(
frame.Orientation!.C!.StatusCode,
Is.EqualTo(StatusCodes.BadCommunicationError));
});
}
[Test]
public async Task PollOnlyProviderKeepsAGlobalLocationFreshAsync()
{
GlobalLocationState state = await CreateGlobalLocationAsync()
.ConfigureAwait(false);
// Poll fast so the test does not wait on the one-second default.
state.MinimumSamplingInterval = 20.0;
var provider = new PollOnlyGlobalProvider(
CreateGlobalSample("robot", 8.0, includeOptionalFields: false));
PositioningProviderSubscription subscription =
await m_builder.BindGlobalLocationAsync(
state,
provider,
"robot").ConfigureAwait(false);
Assert.That(state.Position!.Longitude!.Value, Is.EqualTo(8.0));
// A provider that cannot push must still be polled, otherwise the
// Variable would serve its initial value with Good status forever.
provider.Publish(
CreateGlobalSample("robot", 9.0, includeOptionalFields: false));
await WaitUntilAsync(
() => state.Position!.Longitude!.Value == 9.0).ConfigureAwait(false);
subscription.Dispose();
Assert.Multiple(() =>
{
Assert.That(provider.WatchCallCount, Is.Zero);
Assert.That(provider.ReadCallCount, Is.GreaterThan(1));
Assert.That(state.Position!.Longitude!.Value, Is.EqualTo(9.0));
});
}
private async ValueTask<GlobalLocationState> CreateGlobalLocationAsync()
{
GlobalLocationState state = m_builder.AttachGlobalLocation(
m_owner,
new QualifiedName("GlobalLocation", m_namespaceIndex),
m_owner.NodeId,
4326);
await m_builder.RegisterAsync(state).ConfigureAwait(false);
return state;
}
private static GeoLocationSample CreateGlobalSample(
string sourceId,
double longitude,
bool includeOptionalFields)
{
var position = new GeoPosition(
47.0,
longitude,
Height: includeOptionalFields ? 500.0 : null,
Accuracy: includeOptionalFields ? 0.1 : null,
Floor: includeOptionalFields ? 2.0f : null,
EpsgCode: 4326);
return new GeoLocationSample(
position,
includeOptionalFields ? new GeoOrientation(0.0, 0.0, 90.0) : null,
default,
StatusCodes.Good,
DateTimeUtc.Now,
sourceId);
}
private static ThreeDFrame CreateFrame(double x)
{
return new ThreeDFrame
{
CartesianCoordinates = new ThreeDCartesianCoordinates { X = x },
Orientation = new ThreeDOrientation()
};
}
private static EUInformation CreateUnit(string unitId, string displayName)
{
return new EUInformation(
unitId,
displayName,
"http://www.opcfoundation.org/UA/units/un/cefact");
}
private static async Task WaitUntilAsync(Func<bool> condition)
{
for (int i = 0; i < 100; i++)
{
if (condition())
{
return;
}
await Task.Delay(10).ConfigureAwait(false);
}
Assert.Fail("The provider update was not applied before the timeout.");
}
/// <summary>
/// A provider that cannot push, so the host must poll it.
/// </summary>
private sealed class PollOnlyGlobalProvider : IGeoLocationProvider
{
public PollOnlyGlobalProvider(GeoLocationSample initial)
{
m_current = initial;
}
public bool SupportsPush => false;
public int ReadCallCount => m_readCallCount;
public int WatchCallCount => m_watchCallCount;
public void Publish(GeoLocationSample sample)
{
lock (m_gate)
{
m_current = sample;
}
}
public ValueTask<GeoLocationSample> ReadAsync(
string sourceId,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref m_readCallCount);
lock (m_gate)
{
return new ValueTask<GeoLocationSample>(m_current);
}
}
public async IAsyncEnumerable<GeoLocationSample> WatchAsync(
string sourceId,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref m_watchCallCount);
await Task.Yield();
yield break;
}
private readonly Lock m_gate = new();
private GeoLocationSample m_current;
private int m_readCallCount;
private int m_watchCallCount;
}
private sealed class ControlledGlobalProvider :
IGeoLocationProvider,
IDisposable
{
public bool SupportsPush => true;
private readonly ConcurrentQueue<GeoLocationSample> m_samples = new();
private readonly SemaphoreSlim m_available = new(0);
private readonly GeoLocationSample m_initial;
private readonly TaskCompletionSource<bool> m_cancellationObserved =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public ControlledGlobalProvider(GeoLocationSample initial)
{
m_initial = initial;
}
public Task CancellationObserved => m_cancellationObserved.Task;
public void Publish(GeoLocationSample sample)
{
m_samples.Enqueue(sample);
m_available.Release();
}
public ValueTask<GeoLocationSample> ReadAsync(
string sourceId,
CancellationToken cancellationToken)
{
return new ValueTask<GeoLocationSample>(m_initial);
}
public async IAsyncEnumerable<GeoLocationSample> WatchAsync(
string sourceId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
try
{
while (true)
{
await m_available.WaitAsync(cancellationToken)
.ConfigureAwait(false);
if (m_samples.TryDequeue(
out GeoLocationSample sample))
{
yield return sample;
}
}
}
finally
{
if (cancellationToken.IsCancellationRequested)
{
m_cancellationObserved.TrySetResult(true);
}
}
}
public void Dispose()
{
m_available.Dispose();
}
}
private sealed class FailingGlobalProvider : IGeoLocationProvider
{
public bool SupportsPush => true;
private readonly GeoLocationSample m_initial;
private readonly GeoLocationSample m_update;
public FailingGlobalProvider(
GeoLocationSample initial,
GeoLocationSample update)
{
m_initial = initial;
m_update = update;
}
public ValueTask<GeoLocationSample> ReadAsync(
string sourceId,
CancellationToken cancellationToken)
{
return new ValueTask<GeoLocationSample>(m_initial);
}
public async IAsyncEnumerable<GeoLocationSample> WatchAsync(
string sourceId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
yield return m_update;
throw new InvalidOperationException("Provider failed.");
}
}
private sealed class FailingRelativeProvider :
IRelativeSpatialLocationProvider
{
private readonly RelativeSpatialLocationSample m_initial;
private readonly RelativeSpatialLocationSample m_update;
public FailingRelativeProvider(
RelativeSpatialLocationSample initial,
RelativeSpatialLocationSample update)
{
m_initial = initial;
m_update = update;
}
public ValueTask<RelativeSpatialLocationSample> ReadAsync(
string sourceId,
CancellationToken cancellationToken)
{
return new ValueTask<RelativeSpatialLocationSample>(m_initial);
}
public async IAsyncEnumerable<RelativeSpatialLocationSample>
WatchAsync(
string sourceId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
yield return m_update;
throw new InvalidOperationException("Provider failed.");
}
}
}
}