Files
romanett 7212607f16 OPC UA Part 3 compliance: permissions, Write semantics, NodeManagement, NodeSet DataTypes, locales (#4675)
# Description

Fixes the High and Medium findings of an OPC UA Part 3 (OPC 10000-3,
Address Space Model) compliance audit. Each finding was checked against
the full spec text (Parts 3, 4, 5, 6, 14 and 18) before it was fixed.
Every fix comes with regression tests that fail without it.

### Role permissions (Part 3 §8.55, Part 4 §5.12.2.2)
- The type-hierarchy exemption in
`MasterNodeManager.ValidateRolePermissions` /
`ValidateAccessRestrictions` now covers only Browse, Read and
ReadRolePermissions. Type nodes stay visible to generic clients, which
was the intent of #3705; every other permission bit is now enforced on
them.
- `ReceiveEvents` is enforced on the EventType node. The standard
NodeSet grants it on `AuditEventType` and its subtypes only to
SecurityAdmin, so only SecurityAdmin sessions now receive audit events.
- Call checks the `Call` permission and AccessRestrictions on the Object
as well as on the Method.
- When a Call's `methodId` is the Method declaration of the ObjectType,
the Object's Method with the same BrowseName is permission-checked and
invoked. The declaration is used only if the Object has no such Method.
This applies to `AsyncCustomNodeManager`, `CustomNodeManager2` and the
sample node manager.

### Write service (Part 3 §5.6.2, §8.40, §8.57; Part 4 §5.11.4)
- The synchronous `OnWriteValue` path (fluent `OnWrite`) checks DataType
and ValueRank first (`Bad_TypeMismatch`). After an IndexRange write it
merges the slice into the cached value instead of replacing it.
- StatusWrite / TimestampWrite are enforced (`Bad_WriteNotSupported`).
- Undefined enumeration values and invalid OptionSet writes return
`Bad_OutOfRange`. OptionSet writes merge only the valid bits into the
stored value.
- New `ReportPropertyValueChanged` / `ReportSemanticChange` let server
code raise SemanticChangeEvents for changes it makes itself.
- Required follow-up (Part 14 §6.2.11.1): if an external target rejects
a timestamp, the PubSub subscriber's TargetVariables writer retries
without it.

### NodeManagement and Call arguments (Part 3 §5.5.2, §5.7.3, §6.4.2;
Part 4 §5.8, §5.12.2)
- AddNodes creates the Mandatory InstanceDeclarations of the
TypeDefinition and rejects abstract or Interface types
(`Bad_TypeDefinitionInvalid`).
- AddReferences, DeleteReferences and DeleteNodes bump NodeVersion and
raise GeneralModelChangeEvents.
- Clients may omit trailing optional input arguments
(`HasOptionalInputArgumentDescription`).

### NodeSet DataTypes (Part 3 §5.8.3, §8.48; Part 6 Annex F.12)
- `UANodeSet.Import` completes structure definitions with the base
type's fields (base fields first). It creates definitions for structures
without fields and sets `DefaultEncodingId`.
`StructureDefinition.FirstExplicitFieldIndex` marks the subtype's own
fields.
- `DataTypeState` no longer mutates the stored definition on read.

### Locales (Part 3 §5.2.5, §8.5; Part 4 §5.4)
- Read, Browse and data change notifications return the translation that
best matches the session's LocaleIds, or all translations for `mul`. The
selection lives in the server dispatcher and is built on
`ResourceManager`: the new `TranslateValue` calls the overridable
`Translate`.
- Locale ids are matched case-insensitively, and fallback translations
carry their real locale.

### Client complex types (Part 6 §5.1.7)
- The Reflection.Emit builder encodes only `IsOptional` subtyped fields
as ExtensionObject. It also supports OptionSet subtypes: an unsupported
type is skipped instead of aborting the whole load.
- `TypeInfo.GetXmlName` uses the data type's XML name for dynamic
structures and OptionSets (the XML body element was `<Structure>`). Both
builders now produce identical XML.

## Behaviour changes
All are documented in `docs/MigrationGuide.md`.
- **Audit events:** only SecurityAdmin receives them. The CTT "Auditing
Connections" unit needs a SecurityAdmin user (e.g. sysadmin);
`AuditingOperationTests` was updated.
- **Write timestamps and status:** clients writing a SourceTimestamp or
a non-Good StatusCode get `Bad_WriteNotSupported` unless the Variable
has StatusWrite/TimestampWrite. No variable in `src/` sets these bits.
- **AddNodes:** it rejects abstract types (including `BaseVariableType`
and `BaseEventType`), and new instances now come with child nodes.
- **Call:** the Object needs the Call permission. The Object's own
Method runs even when the call names the type declaration.

## Not included
- Low-severity findings: missing validation on optional services and
author-side NodeSet rules.
- LocalizedText nested inside structures or Variant arrays, and in
HistoryRead or Call outputs.

## Testing
net10.0, Debug, one suite at a time on the merged branch. All suites
below pass:

| Suite | Passed |
|---|---|
| Types | 11430 |
| Core.Encoders | 2947 |
| Core.Schema | 179 |
| Core.Security | 558 |
| Client | 2761 |
| Server | 8210 |
| InformationModel | 1080 |
| Features | 145 |
| History | 536 |
| Di | 455 |
| Machinery | 167 |
| Robotics | 661 |
| Robotics.Intent | 22 |
| Pumps | 75 |
| Scales | 122 |
| Positioning | 69 |
| ISA95 | 144 |
| Vision | 484 |
| PubSub | 1490 |
| PubSub.Adapter | 151 |
| PubSub.Server | 177 |
| Redundancy.Server | 856 |
| Lds | 173 |
| Configuration | 241 |
| WotCon | 1496 |

- **Client.ComplexTypes:** one test wrote a SourceTimestamp to a
variable without TimestampWrite. It is fixed in this PR, and the suite
has not been re-run since.
- **Core:** 6068 passed, 1 failure in
`ActivityTraceFlags_Metrics_ShouldRecordMetricsOnly`. That telemetry
test passed when re-run alone, so it looks flaky.
- **Still running:** Sessions, Subscriptions (Classic, Durable,
standard) and Interop. Results will follow on this PR.
- `UA.slnx` builds with 0 errors for net10.0 and net48, with no new
analyzer warnings in changed files.

## Related Issues

- Part 3 compliance audit (no tracking issue)

## 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.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:43:59 +00:00

1393 lines
62 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 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.Diagnostics;
using System.Globalization;
using System.Numerics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Server;
using Opc.Ua.Server.Fluent;
using Opc.Ua.Server.Historian;
using Opc.Ua.Server.Historian.InMemory;
using Opc.Ua.Test;
using Quickstarts.Servers;
using UaBrowseNames = Opc.Ua.BrowseNames;
using UaKeyValuePair = Opc.Ua.KeyValuePair;
namespace Quickstarts.ReferenceServer
{
/// <summary>
/// A node manager for a server that exposes several variables.
/// </summary>
[NodeManager(NamespaceUri = Namespaces.ReferenceServer)]
public partial class ReferenceNodeManager : IConformanceContributor
{
/// <summary>
/// Initializes the node manager.
/// </summary>
public ReferenceNodeManager(
IServerInternal server,
ApplicationConfiguration configuration,
bool useSamplingGroups = false)
: base(
server,
configuration,
useSamplingGroups,
server.Telemetry.CreateLogger<ReferenceNodeManager>(),
Namespaces.ReferenceServer)
{
// use suitable defaults if no configuration exists.
}
/// <summary>
/// The conformance units this node manager enables: the reference
/// server's always-supported base set, plus the Historical Access units
/// once history archiving has been turned on (see
/// <see cref="EnableHistoryArchivingAsync"/>), so the server advertises
/// the HA facet only when the feature is actually present.
/// </summary>
public ArrayOf<QualifiedName> ConformanceUnits =>
m_conformanceUnits;
/// <summary>
/// The capability-gated Historical Access Server profile URIs enabled
/// by this node manager.
/// </summary>
public ArrayOf<string> ServerProfiles =>
m_historicalProfiles;
private HistorianBuilder? EventHistorianBuilder { get; set; }
/// <summary>
/// An overrideable version of the Dispose.
/// </summary>
protected override void Dispose(bool disposing)
{
if (disposing)
{
m_historian?.Dispose();
m_historian = null;
}
// Let the base FluentNodeManagerBase drain the fluent simulation
// loop (Simulations.Dispose) before the semaphore the OnTick
// handler takes is disposed, so no in-flight tick can ever observe
// a disposed semaphore.
base.Dispose(disposing);
if (disposing)
{
m_semaphore?.Dispose();
}
}
/// <inheritdoc/>
/// <remarks>
/// Enables the OPC UA NodeManagement service set (AddNodes /
/// DeleteNodes / AddReferences / DeleteReferences) so that conformance
/// tests and clients can mutate the address space at runtime. New
/// nodes live in this node manager's namespace; cross-NodeManager
/// references are written through <see cref="MasterNodeManager"/>.
/// </remarks>
public override bool AllowNodeManagement => true;
/// <summary>
/// Loads the predefined nodes from the NodeSet2 model and then enables
/// the runtime history archiving behaviour, once every node has been
/// materialized and its reverse references established.
/// </summary>
protected override async ValueTask LoadPredefinedNodesAsync(
ISystemContext context,
IDictionary<NodeId, IList<IReference>> externalReferences,
CancellationToken cancellationToken = default)
{
await base.LoadPredefinedNodesAsync(context, externalReferences, cancellationToken).ConfigureAwait(false);
await m_semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Prio 1 / Prio 2 not possible: the simulated variables are baked
// into the model, but the periodic value simulation needs them
// collected into a runtime list that the fluent
// Simulation().OnTick() loop pushes fresh random values to. The
// fluent surface exposes only a bare periodic callback with no
// per-variable random-value model, so collecting the individual
// dynamic nodes stays imperative here. The loop itself is wired
// through the fluent builder in Configure().
RegisterSimulationVariables();
InitializeMissingStaticValues();
EnableStatusAndTimestampWrites();
// Reset the random generator and generate boundary values so the
// fluent simulation loop (registered in Configure and started
// after Seal) always has a generator ready for the first tick.
ResetRandomGenerator(100, 1);
// Prio 1 / Prio 2 not possible: history archiving registers an
// in-memory historian provider, seeds the sample history and
// refreshes the HistoricalDataConfiguration companion objects at
// runtime. The Historizing attribute, the history access-level
// bits and the companion nodes are all baked into the NodeSet2
// model; the archive contents and provider wiring remain runtime
// services and therefore run here, after every predefined node
// has been loaded.
//
// The CTT root folder, its EventNotifier attribute and the
// inverse Server -> HasNotifier reference are baked into the
// model, so base.AddReverseReferencesAsync (invoked by
// base.LoadPredefinedNodesAsync above) both materializes the root
// and auto-registers it with the runtime notifier table - no
// explicit root creation or notifier registration is required.
await EnableHistoryArchivingAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
m_semaphore.Release();
}
}
/// <summary>
/// Discovers the simulated CTT variables from the loaded NodeSet2 model
/// and collects them into the runtime dynamic-node list so the periodic
/// <see cref="RunSimulationStepAsync"/> loop can push fresh random
/// values to them. The simulated nodes are exactly the data variables
/// under the "Scalar_Simulation" subtree (their string node ids are
/// prefixed "Scalar_Simulation_"), excluding the two control variables
/// (Interval and Enabled), so no hard-coded node list is required.
/// </summary>
private void RegisterSimulationVariables()
{
m_dynamicNodes.Clear();
foreach (NodeState node in PredefinedNodes.Values)
{
if (node is BaseDataVariableState variable &&
variable.NodeId.TryGetValue(out string identifier) &&
identifier.StartsWith(SimulationNodePrefix, StringComparison.Ordinal) &&
!string.Equals(identifier, SimulationIntervalNodeName, StringComparison.Ordinal) &&
!string.Equals(identifier, SimulationEnabledNodeName, StringComparison.Ordinal))
{
m_dynamicNodes.Add(variable);
}
}
}
/// <summary>
/// The reference server accepts a StatusCode and SourceTimestamp written
/// together with the Value on every writable data variable (CTT conformance
/// units "Attribute Write StatusCode &amp; Timestamp" and Data Access
/// PercentDeadband). OPC 10000-3 §8.57 requires the StatusWrite and
/// TimestampWrite AccessLevel bits for that, so they are added wherever
/// CurrentWrite is granted. The NodeSet2 model only carries the basic bits.
/// </summary>
private void EnableStatusAndTimestampWrites()
{
const byte statusAndTimestampWrite = (byte)(AccessLevels.StatusWrite | AccessLevels.TimestampWrite);
foreach (NodeState node in PredefinedNodes.Values)
{
if (node is not BaseDataVariableState variable)
{
continue;
}
if ((variable.AccessLevel & AccessLevels.CurrentWrite) != 0)
{
variable.AccessLevel |= statusAndTimestampWrite;
}
if ((variable.UserAccessLevel & AccessLevels.CurrentWrite) != 0)
{
variable.UserAccessLevel |= statusAndTimestampWrite;
}
}
}
private void InitializeMissingStaticValues()
{
SetPredefinedVariableValue(
"Scalar_Static_Arrays_Integer",
Variant.From(CreateArray(10, i => (long)i).ToArrayOf()));
SetPredefinedVariableValue(
"Scalar_Static_Arrays_Number",
Variant.From(CreateArray(10, i => (double)i).ToArrayOf()));
SetPredefinedVariableValue(
"Scalar_Static_Arrays_UInteger",
Variant.From(CreateArray(10, i => (ulong)i).ToArrayOf()));
SetPredefinedVariableValue(
"Scalar_Static_Arrays2D_Integer",
Variant.From(CreateMatrix(5, 5, (r, c) => (long)((r * 5) + c))));
SetPredefinedVariableValue(
"Scalar_Static_Arrays2D_Number",
Variant.From(CreateMatrix(5, 5, (r, c) => (double)((r * 5) + c))));
SetPredefinedVariableValue(
"Scalar_Static_Arrays2D_UInteger",
Variant.From(CreateMatrix(5, 5, (r, c) => (ulong)((r * 5) + c))));
SetPredefinedVariableValue(
"Scalar_Static_ArrayDynamic_Integer",
Variant.From(CreateArray(10, i => (long)i).ToArrayOf()));
SetPredefinedVariableValue(
"Scalar_Static_ArrayDynamic_Number",
Variant.From(CreateArray(10, i => (double)i).ToArrayOf()));
SetPredefinedVariableValue(
"Scalar_Static_ArrayDynamic_UInteger",
Variant.From(CreateArray(10, i => (ulong)i).ToArrayOf()));
SetPredefinedVariableValue("DataAccess_ArrayItemType_YArray", Variant.From(s_doubleArray));
SetPredefinedVariableValue(
"DataAccess_ArrayItemType_XYArray",
Variant.FromStructure(new XVType[]
{
new() { X = 0.0, Value = 0.0f },
new() { X = 1.0, Value = 1.0f },
new() { X = 2.0, Value = 4.0f },
new() { X = 3.0, Value = 9.0f },
new() { X = 4.0, Value = 16.0f }
}.ToMatrixOf(5)));
SetPredefinedVariableValue(
"DataAccess_ArrayItemType_Image",
Variant.From(MatrixOf<double>.CreateFromArray(new double[,]
{
{ 0.0, 1.0, 2.0 },
{ 3.0, 4.0, 5.0 }
})));
SetPredefinedVariableValue(
"DataAccess_ArrayItemType_Cube",
Variant.From(MatrixOf<double>.CreateFromArray(new double[,,]
{
{ { 0.0, 1.0 }, { 2.0, 3.0 } },
{ { 4.0, 5.0 }, { 6.0, 7.0 } }
})));
SetPredefinedVariableValue(
"DataAccess_ArrayItemType_NDimension",
Variant.From(MatrixOf<double>.CreateFromArray(new double[,]
{
{ 0.0, 1.0, 2.0 },
{ 3.0, 4.0, 5.0 }
})));
// MinimumSamplingInterval is an optional attribute (Part 3 §5.6.2). One static
// DataItem does not provide it, so clients can verify that the server revises the
// sampling interval of such a node to MinSupportedSampleRate (CTT Base Info Server
// Capabilities 2 002.js requires a node that returns BadAttributeIdInvalid). A DA
// item is used because the Address Space WriteMask cases write every attribute of
// the static scalars back.
if (FindPredefinedNode<BaseVariableState>(
new NodeId("DataAccess_DataItem_String", NamespaceIndex)) is BaseVariableState stringItem)
{
stringItem.MinimumSamplingInterval = MinimumSamplingIntervals.Indeterminate;
stringItem.OnReadMinimumSamplingInterval = static (context, node, ref value) =>
StatusCodes.BadAttributeIdInvalid;
}
}
private void SetPredefinedVariableValue(string identifier, Variant value)
{
if (FindPredefinedNode<BaseVariableState>(new NodeId(identifier, NamespaceIndex)) is not BaseVariableState node)
{
return;
}
node.Value = value;
node.StatusCode = StatusCodes.Good;
node.Timestamp = DateTimeUtc.Now;
node.ClearChangeMasks(SystemContext, false);
}
private ServiceResult OnWriteInterval(ISystemContext context, NodeState node, ref Variant value)
{
try
{
if (!value.TryGetValue(out ushort interval) || interval == 0)
{
return StatusCodes.BadOutOfRange;
}
Volatile.Write(ref m_simulationIntervalMilliseconds, interval);
return ServiceResult.Good;
}
catch (Exception e)
{
m_logger.ErrorWritingIntervalVariable(e);
return ServiceResult.Create(e, StatusCodes.Bad, "Error writing Interval variable.");
}
}
private void ResetRandomGenerator(int seed, int boundaryValueFrequency = 0)
{
m_randomSource = new RandomSource(seed);
m_generator = new DataGenerator(m_randomSource, Server.Telemetry)
{
BoundaryValueFrequency = boundaryValueFrequency
};
}
private Variant GetNewValue(BaseVariableState variable)
{
Debug.Assert(m_generator != null, "Need a random generator!");
// Supply a concrete size for every dimension of a multi-dimensional
// array so the generated matrix is self-consistent with the node's
// ArrayDimensions attribute. Without this the CTT multi-dimensional
// read/index-range tests skip the node ("Length of second dimension:
// 0"). Scalars and single-dimension arrays keep the historic random
// length.
uint[] dimensions = variable.ValueRank >= ValueRanks.TwoDimensions
? CreateFixedArrayDimensions(variable.ValueRank)
: [DefaultArrayLength];
// For variables whose DataType is Variant (BaseDataType) the CTT
// only accepts values whose concrete BuiltInType is a simple type
// (< XmlElement, i.e. BuiltInType value 16). The DataGenerator can
// return any BuiltInType, so keep retrying until it produces an
// acceptable one alongside the existing null-value retry.
bool isVariantDataType = TypeInfo.GetBuiltInType(variable.DataType, Server.TypeTree) ==
BuiltInType.Variant;
Variant value = default;
for (int retryCount = 0;
(value.IsNull ||
(isVariantDataType && value.TypeInfo.BuiltInType >= BuiltInType.XmlElement)) &&
retryCount < 10;
retryCount++)
{
value = m_generator!.GetRandom(
variable.DataType,
variable.ValueRank,
dimensions,
Server.TypeTree);
}
// The CTT (Monitor Value Change V2 020.js) skips a ByteString array
// with any element shorter than four bytes. The random generator can
// produce shorter values, so pad every element up to the minimum length.
if (variable.DataType == DataTypeIds.ByteString &&
variable.ValueRank == ValueRanks.OneDimension &&
value.TryGetValue(out ArrayOf<ByteString> byteStringArray))
{
ByteString[] byteStrings = byteStringArray.ToArray()!;
for (int ii = 0; ii < byteStrings.Length; ii++)
{
byteStrings[ii] = EnsureMinimumByteStringLength(byteStrings[ii], 4);
}
value = Variant.From(byteStrings.ToArrayOf());
}
return value;
}
/// <summary>
/// Returns a ByteString that is at least <paramref name="minimumLength"/>
/// bytes long, right-padding the original bytes with zeros when needed.
/// </summary>
private static ByteString EnsureMinimumByteStringLength(ByteString value, int minimumLength)
{
ReadOnlySpan<byte> bytes = value.IsNull ? default : value.Span;
if (bytes.Length >= minimumLength)
{
return value;
}
byte[] padded = new byte[minimumLength];
bytes.CopyTo(padded);
return ByteString.From(padded);
}
/// <summary>
/// Creates a fixed-size dimension array (one entry per dimension) for a
/// multi-dimensional array so its value and ArrayDimensions attribute stay
/// deterministic and consistent.
/// </summary>
private static uint[] CreateFixedArrayDimensions(int valueRank)
{
uint[] dimensions = new uint[valueRank];
for (int ii = 0; ii < valueRank; ii++)
{
dimensions[ii] = MultiDimensionalArrayLength;
}
return dimensions;
}
/// <summary>
/// Executes a single simulation tick: pushes a fresh random value to
/// every registered dynamic node. Invoked from the fluent
/// <c>Simulation().OnTick(...)</c> loop wired in <c>Configure</c>. The
/// loop serializes its ticks, so this never re-enters itself; the
/// semaphore only guards against concurrent address-space mutation
/// (history archiving, node loading).
/// </summary>
private async ValueTask RunSimulationStepAsync(TimeSpan elapsed, CancellationToken cancellationToken)
{
if (!Volatile.Read(ref m_simulationEnabled))
{
m_simulationElapsedTicks = 0;
return;
}
int intervalMilliseconds = Volatile.Read(ref m_simulationIntervalMilliseconds);
long intervalTicks = TimeSpan.FromMilliseconds(intervalMilliseconds).Ticks;
m_simulationElapsedTicks += elapsed.Ticks;
if (m_simulationElapsedTicks < intervalTicks)
{
return;
}
m_simulationElapsedTicks = 0;
await m_semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
DateTimeUtc timeStamp = DateTimeUtc.Now;
foreach (BaseDataVariableState variable in m_dynamicNodes)
{
variable.Value = GetNewValue(variable);
variable.Timestamp = timeStamp;
variable.ClearChangeMasks(SystemContext, false);
}
}
finally
{
m_semaphore.Release();
}
}
private readonly SemaphoreSlim m_semaphore = new(1, 1);
private RandomSource m_randomSource = null!;
private DataGenerator m_generator = null!;
private bool m_simulationEnabled = true;
private int m_simulationIntervalMilliseconds = 1000;
private long m_simulationElapsedTicks;
private readonly List<BaseDataVariableState> m_dynamicNodes = [];
/// <summary>
/// Tick resolution used to emulate the old reschedulable timer on top
/// of the fluent simulation loop.
/// </summary>
private static readonly TimeSpan s_simulationTickInterval = TimeSpan.FromMilliseconds(100);
/// <summary>
/// Default random length used when generating single-dimension array values.
/// </summary>
private const uint DefaultArrayLength = 10;
/// <summary>
/// Fixed length used for every dimension of a generated multi-dimensional
/// array so its value and ArrayDimensions attribute stay deterministic.
/// </summary>
private const uint MultiDimensionalArrayLength = 5;
/// <summary>
/// String node-id prefix shared by every variable under the
/// "Scalar_Simulation" subtree. Used to discover the simulated nodes
/// from the loaded model.
/// </summary>
private const string SimulationNodePrefix = "Scalar_Simulation_";
/// <summary>
/// String node id of the read-only simulation Interval control variable,
/// excluded from the discovered dynamic-node set.
/// </summary>
private const string SimulationIntervalNodeName = "Scalar_Simulation_Interval";
/// <summary>
/// String node id of the simulation Enabled control variable, excluded
/// from the discovered dynamic-node set.
/// </summary>
private const string SimulationEnabledNodeName = "Scalar_Simulation_Enabled";
/// <summary>
/// NodeId identifier of the historizing node whose historian intentionally
/// does not support server timestamps.
/// </summary>
private const string NodeDoesNotSupportServerTimestampNodeName
= "Scalar_Static_NodeDoesNotSupportServerTimestamp";
/// <summary>
/// NodeId identifier of the generic StructuredHistoryData sample.
/// </summary>
private const string StructuredHistoryNodeName =
"Historical_KeyValuePairs";
/// <summary>
/// Start of the history segment that every historizing scalar variable gets at a
/// fixed date, in addition to the samples seeded relative to the server start. The
/// CTT aggregate test cases 005-05/005-06 need a block of consecutive non-Good values
/// at an absolute time (the project settings
/// <c>/Server Test/NodeIds/Static/HA Profile/Aggregates/StartOfBadData*</c>), which
/// a history relative to the server start cannot provide.
/// </summary>
private static readonly DateTime s_fixedHistoryStart =
new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
/// <summary>
/// Number of samples in the fixed-date history segment (10 s apart).
/// </summary>
private const int FixedHistorySampleCount = 400;
/// <summary>
/// Sample index the StartOfBadData* settings of samples/UAReferenceServer.ctt.xml
/// point to (<c>2026-01-01T00:25:01.234Z</c>).
/// </summary>
private const int FixedHistoryBadDataSearchStart = 150;
/// <summary>
/// Returns the status of a sample in the fixed-date segment. The layout follows
/// how the CTT (HAAggregateHelper.GetBadData, scripts 1.05.513) searches it:
/// <list type="bullet">
/// <item>Samples 0-149 repeat the pattern of <see cref="GetSeededStatusCode"/>; the
/// CTT uses the first 120 samples as the start data of the other test cases.</item>
/// <item>The search reads 120 samples forward from the configured time (sample 150) and
/// needs a non-Good sample followed by a Good one: samples 150-267 are Good, 268 is Bad.</item>
/// <item>Its "reverse" read sends EndTime = DateTime.MinValue, which means "not
/// specified", so it also reads 120 samples forward (Part 11 §6.5.3). The final read
/// therefore spans sample 269 (150 + 119) to sample 387 (268 + 119), and the
/// contiguous Bad block 275-279 must lie inside it.</item>
/// <item>Samples 280-399 repeat the pattern again (no other consecutive non-Good
/// samples).</item>
/// </list>
/// </summary>
private static StatusCode GetFixedHistoryStatusCode(int sampleIndex)
{
return sampleIndex switch
{
< FixedHistoryBadDataSearchStart => GetSeededStatusCode(sampleIndex),
< 268 => StatusCodes.Good,
268 => StatusCodes.BadDataUnavailable,
< 275 => StatusCodes.Good,
< 280 => StatusCodes.BadDataUnavailable,
_ => GetSeededStatusCode(sampleIndex)
};
}
private InMemoryHistorianProvider? m_historian;
private BaseObjectState? m_historicalEventNotifier;
private ArrayOf<QualifiedName> m_conformanceUnits =
s_baseConformanceUnits;
private ArrayOf<string> m_historicalProfiles =
[];
/// <summary>
/// Base set of conformance units the reference server always supports
/// (core address space, attribute, base info, discovery, method,
/// monitoring, security, session, subscription, transport and view
/// facets). Feature-specific units — e.g. Historical Access — are added
/// on top when the corresponding feature is enabled. Sourced from the
/// OPC UA profile registry (UACore 1.05 ProfileSet).
/// </summary>
private static readonly QualifiedName[] s_baseConformanceUnitNames =
[
new("Address Space Atomicity"),
new("Address Space Base"),
new("Address Space Full Array Only"),
new("Address Space Method"),
new("Attribute Read"),
new("Base Info Base Types"),
new("Base Info Core Structure 2"),
new("Base Info Core Types Folders"),
new("Base Info Date DataTypes"),
new("Base Info Decimal DataType"),
new("Base Info GetMonitoredItems Method"),
new("Base Info Method Argument DataType"),
new("Base Info Method Capabilities"),
new("Base Info ResendData Method"),
new("Base Info SemanticChange Bit"),
new("Base Info Server Capabilities 2"),
new("Base Info Server Capabilities MaxMonitoredItemsQueueSize"),
new("Base Info Server Capabilities Subscriptions"),
new("Base Info ServerType"),
new("Base Info Type Information"),
new("Data Access DataItems"),
new("Discovery Find Servers Self"),
new("Discovery Get Endpoints"),
new("Discovery Register"),
new("Discovery Register2"),
new("Documentation - Core Capacities"),
new("Method Call"),
new("Monitor Basic"),
new("Monitor Items 2"),
new("Monitor Queueing"),
new("Monitor Triggering"),
new("Monitor Value Change V2"),
new("Monitored Items Deadband Filter"),
new("Protocol Reverse Connect Server"),
new("Protocol UA TCP"),
new("Push Model for Global Certificate and TrustList Management"),
new("Security Default ApplicationInstance Certificate"),
new("Security ECC Policy"),
new("Security Invalid user token"),
new("Security Policy Required"),
new("Security User Name Password 2"),
new("Security User X509"),
new("SecurityPolicy Support"),
new("Session Base"),
new("Session Cancel"),
new("Session General Service Behaviour"),
new("Session Multiple"),
new("Subscription Basic"),
new("Subscription Multiple"),
new("Subscription Publish Basic"),
new("Subscription PublishRequest Queue Overflow"),
new("Subscription Retransmission Queue"),
new("Subscription Transfer"),
new("Time Sync - Support"),
new("UA Binary Encoding"),
new("UA Secure Conversation"),
new("View Basic 2"),
new("View RegisterNodes"),
new("View TranslateBrowsePath")
];
/// <summary>
/// The always-supported base conformance units.
/// </summary>
private static readonly ArrayOf<QualifiedName> s_baseConformanceUnits =
s_baseConformanceUnitNames.ToArrayOf();
/// <inheritdoc/>
protected override IHistorianProvider? GetHistorianProvider(NodeState node)
{
return m_historian;
}
/// <summary>
/// Enables history archiving on selected scalar variables using
/// the fluent <see cref="HistorianBuilder"/> API.
/// </summary>
private async Task EnableHistoryArchivingAsync(CancellationToken cancellationToken)
{
m_historian = new InMemoryHistorianProvider(new InMemoryHistorianOptions
{
// The reference server supports conformance tests that write
// arbitrary historical timestamps outside the seeded window.
RawDataRetentionPeriod = TimeSpan.Zero
});
// Make the provider discoverable to the server-wide registry so
// server capabilities (HistoryServerCapabilities) reflect what the
// provider supports. The dispatcher will still prefer the
// per-node-manager override returned by GetHistorianProvider.
if (Server is IHistorianRegistryProvider registry)
{
registry.HistorianRegistry.RegisterDefault(m_historian);
}
// Capabilities advertised per historizing node. StartOfArchive is set
// slightly before the earliest seeded sample so History Access clients
// (and the CTT) can discover the archive window via the installed
// HistoricalDataConfigurationType companion object.
var capabilities = new HistorianNodeCapabilities
{
InsertData = true,
ReplaceData = true,
UpdateData = true,
DeleteRaw = true,
DeleteAtTime = true,
InsertAnnotation = true,
ServerTimestampSupported = true,
Stepped = false,
StartOfArchive = new DateTimeUtc(DateTime.UtcNow.AddSeconds(-10000)),
StartOfOnlineArchive = new DateTimeUtc(DateTime.UtcNow.AddSeconds(-10000))
};
await EnableHistoricalEventsAsync(
capabilities.StartOfArchive,
cancellationToken).ConfigureAwait(false);
// The dedicated node whose historian does not support server
// timestamps reuses the shared capabilities with
// ServerTimestampSupported cleared, so History Read never returns a
// ServerTimestamp for it (backs the CTT
// "HA Profile > NodeDoesNotSupportServerTimestamp" slot).
HistorianNodeCapabilities noServerTimestampCapabilities =
capabilities with { ServerTimestampSupported = false };
var historianBuilder = new HistorianBuilder(Server);
historianBuilder.UseProvider(m_historian);
try
{
// Discover the historized nodes directly from the loaded model:
// every variable that carries Historizing="true" (baked into the
// NodeSet2 model together with the HistoryRead / HistoryWrite
// access-level bits) is a history node. Snapshot them first because
// installing each node's HA Configuration companion below adds nodes
// to PredefinedNodes, which would otherwise invalidate the
// enumerator.
List<BaseVariableState> historizedNodes = [];
foreach (NodeState node in PredefinedNodes.Values)
{
if (node is BaseVariableState variable && variable.Historizing)
{
historizedNodes.Add(variable);
}
}
foreach (BaseVariableState variable in historizedNodes)
{
NodeId nodeId = variable.NodeId;
// The dedicated node whose historian does not support server
// timestamps is identified by its node id; everything else uses
// the shared capabilities. Only the runtime historian
// registration and seeding remain here.
bool noServerTimestamp = nodeId.TryGetValue(out string identifier) &&
string.Equals(
identifier,
NodeDoesNotSupportServerTimestampNodeName,
StringComparison.Ordinal);
HistorianNodeCapabilities nodeCapabilities = noServerTimestamp
? noServerTimestampCapabilities
: capabilities;
// nodes with the fixed-date segment start their archive there.
if (HasFixedHistorySegment(variable))
{
nodeCapabilities = nodeCapabilities with
{
StartOfArchive = new DateTimeUtc(s_fixedHistoryStart),
StartOfOnlineArchive = new DateTimeUtc(s_fixedHistoryStart)
};
}
historianBuilder.Historize(
variable,
historyAccessLevel: 0,
setHistorizing: false,
systemContext: SystemContext,
capabilities: nodeCapabilities,
autoCapture: false);
BaseInstanceState? annotations = variable.FindChild(
SystemContext,
new QualifiedName(UaBrowseNames.Annotations));
if (annotations != null)
{
if (annotations is BaseVariableState annotationVariable)
{
annotationVariable.AccessLevel = (byte)(
variable.AccessLevel &
(AccessLevels.HistoryRead |
AccessLevels.HistoryWrite));
annotationVariable.UserAccessLevel = (byte)(
variable.UserAccessLevel &
(AccessLevels.HistoryRead |
AccessLevels.HistoryWrite));
}
await AddPredefinedNodeAsync(
SystemContext,
annotations,
cancellationToken).ConfigureAwait(false);
}
await SeedHistoricalNodeAsync(variable, cancellationToken).ConfigureAwait(false);
// Attach a HistoricalDataConfigurationType companion object
// (browse name "HA Configuration") and wire it via the
// HasHistoricalConfiguration reference so History Access aggregate
// clients and the CTT can discover the node's configuration
// (OPC UA Part 11 5.2.3).
HistoricalDataConfigurationState config =
await HistoricalDataConfigurationInstaller.EnsureInstalledAsync(
SystemContext,
variable,
m_historian,
cancellationToken).ConfigureAwait(false);
await AddPredefinedNodeAsync(
SystemContext,
config,
cancellationToken).ConfigureAwait(false);
}
}
finally
{
await historianBuilder.DisposeAsync().ConfigureAwait(false);
}
await EnableStructuredHistoryAsync(
capabilities.StartOfArchive,
cancellationToken).ConfigureAwait(false);
await UpdateHistoricalConformanceClaimsAsync(
cancellationToken).ConfigureAwait(false);
}
private async ValueTask EnableHistoricalEventsAsync(
DateTimeUtc startOfArchive,
CancellationToken cancellationToken)
{
BaseObjectState? notifier = FindPredefinedNode<BaseObjectState>(
new NodeId("CTT", NamespaceIndex)) ??
throw new ServiceResultException(
StatusCodes.BadNodeIdUnknown,
"The ReferenceServer CTT event notifier was not loaded.");
m_historicalEventNotifier = notifier;
SimpleAttributeOperand eventTypeField = CreateHistoricalEventField(
UaBrowseNames.EventType);
SimpleAttributeOperand timeField = CreateHistoricalEventField(
UaBrowseNames.Time);
var capabilities = new HistorianNodeCapabilities
{
ReadRawData = false,
ReadModifiedData = false,
ReadAtTime = false,
ReadProcessedData = false,
ReadEventHistory = true,
InsertEvent = true,
ReplaceEvent = true,
UpdateEvent = true,
DeleteEvent = true,
EventTypes = [ObjectTypeIds.BaseEventType],
MandatoryEventFields = [eventTypeField, timeField],
SortByEventFields = [timeField],
StartOfArchive = startOfArchive,
StartOfOnlineArchive = startOfArchive
};
// ServerInternalData owns registered builders and disposes their
// capture pipelines during shutdown.
EventHistorianBuilder ??= new HistorianBuilder(Server);
EventHistorianBuilder.UseProvider(m_historian!);
HistoricalEventConfigurationState? configuration =
await EventHistorianBuilder.HistorizeEventsAsync(
notifier,
SystemContext,
capabilities: capabilities,
cancellationToken: cancellationToken).ConfigureAwait(false) ??
throw new ServiceResultException(
StatusCodes.BadConfigurationError,
"The historical event configuration was not installed.");
await AddPredefinedNodeAsync(
SystemContext,
configuration,
cancellationToken).ConfigureAwait(false);
DateTimeUtc eventTime = DateTime.UtcNow.AddMinutes(-15);
var eventId = ByteString.From(Guid.NewGuid().ToByteArray());
var fields = new Dictionary<string, Variant>(StringComparer.Ordinal)
{
[UaBrowseNames.EventId] = Variant.From(eventId),
[UaBrowseNames.EventType] = Variant.From(ObjectTypeIds.BaseEventType),
[UaBrowseNames.SourceNode] = Variant.From(notifier.NodeId),
[UaBrowseNames.SourceName] = Variant.From("ReferenceServer"),
[UaBrowseNames.Time] = Variant.From(eventTime),
[UaBrowseNames.Message] = Variant.From(
new LocalizedText("ReferenceServer historical event")),
[UaBrowseNames.Severity] = Variant.From(EventSeverity.Medium)
};
var record = new HistorianEventRecord(
eventId,
ObjectTypeIds.BaseEventType,
eventTime,
fields.ToArrayOf());
using var operationContext = new OperationContext(
new RequestHeader(),
null,
RequestType.HistoryUpdate,
RequestLifetime.None);
var systemContext = new ServerSystemContext(Server, operationContext);
var historianContext = new HistorianOperationContext(
systemContext,
operationContext,
null,
HistoryUpdateType.Insert);
_ = await m_historian!.InsertEventsAsync(
historianContext,
notifier.NodeId,
[record],
cancellationToken).ConfigureAwait(false);
}
private async ValueTask EnableStructuredHistoryAsync(
DateTimeUtc startOfArchive,
CancellationToken cancellationToken)
{
BaseObjectState? parent = FindPredefinedNode<BaseObjectState>(
new NodeId("CTT", NamespaceIndex)) ??
throw new ServiceResultException(
StatusCodes.BadNodeIdUnknown,
"The ReferenceServer CTT object was not loaded.");
var initialPair = new UaKeyValuePair
{
Key = new QualifiedName("Pressure", NamespaceIndex),
Value = Variant.From(0.0)
};
var variable = new BaseDataVariableState(parent)
{
SymbolicName = StructuredHistoryNodeName,
ReferenceTypeId = ReferenceTypeIds.HasComponent,
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
NodeId = new NodeId(
StructuredHistoryNodeName,
NamespaceIndex),
BrowseName = new QualifiedName(
StructuredHistoryNodeName,
NamespaceIndex),
DisplayName = new LocalizedText(
"en",
"Structured Historical KeyValuePairs"),
DataType = DataTypeIds.KeyValuePair,
ValueRank = ValueRanks.Scalar,
AccessLevel =
AccessLevels.CurrentRead |
AccessLevels.HistoryRead |
AccessLevels.HistoryWrite,
UserAccessLevel =
AccessLevels.CurrentRead |
AccessLevels.HistoryRead |
AccessLevels.HistoryWrite,
Historizing = true,
Value = Variant.From(
new ExtensionObject(initialPair)),
StatusCode = StatusCodes.Good,
Timestamp = DateTime.UtcNow
};
parent.AddChild(variable);
m_historian!.RegisterStructured(
variable.NodeId,
KeyValuePairStructuredDataKeySelector.Instance,
HistorianNodeCapabilities.StructuredReadWrite with
{
StartOfArchive = startOfArchive,
StartOfOnlineArchive = startOfArchive
});
await AddPredefinedNodeAsync(
SystemContext,
variable,
cancellationToken).ConfigureAwait(false);
HistoricalDataConfigurationState configuration =
await HistoricalDataConfigurationInstaller
.EnsureInstalledAsync(
SystemContext,
variable,
m_historian,
cancellationToken)
.ConfigureAwait(false);
await AddPredefinedNodeAsync(
SystemContext,
configuration,
cancellationToken).ConfigureAwait(false);
DateTimeUtc captureTime = DateTime.UtcNow.AddMinutes(-10);
ArrayOf<DataValue> seed =
[
CreateStructuredHistoryValue(
"Pressure",
captureTime,
42.5),
CreateStructuredHistoryValue(
"Temperature",
captureTime,
21.25)
];
using var operationContext = new OperationContext(
new RequestHeader(),
null,
RequestType.HistoryUpdate,
RequestLifetime.None);
var historianContext = new HistorianOperationContext(
new ServerSystemContext(Server, operationContext),
operationContext,
variable,
HistoryUpdateType.Insert);
HistorianUpdateOutcome<DataValue> outcome =
await m_historian.InsertStructuredDataAsync(
historianContext,
variable.NodeId,
seed,
cancellationToken).ConfigureAwait(false);
for (int i = 0; i < outcome.OperationResults.Count; i++)
{
if (StatusCode.IsBad(outcome.OperationResults[i]))
{
throw new ServiceResultException(
outcome.OperationResults[i],
"The ReferenceServer structured history seed failed.");
}
}
}
private DataValue CreateStructuredHistoryValue(
string key,
DateTimeUtc sourceTimestamp,
double value)
{
var pair = new UaKeyValuePair
{
Key = new QualifiedName(key, NamespaceIndex),
Value = Variant.From(value)
};
return new DataValue(
Variant.From(new ExtensionObject(pair)),
StatusCodes.Good,
sourceTimestamp,
sourceTimestamp);
}
private async ValueTask UpdateHistoricalConformanceClaimsAsync(
CancellationToken cancellationToken)
{
HistorianNodeCapabilities capabilities = await m_historian!
.GetCapabilitiesAsync(
NodeId.Null,
cancellationToken)
.ConfigureAwait(false);
HistorianNodeCapabilities eventCapabilities = await m_historian
.GetCapabilitiesAsync(
new NodeId("CTT", NamespaceIndex),
cancellationToken)
.ConfigureAwait(false);
ArrayOf<HistoricalAccessProfileDescriptor> profiles =
HistorianProfileCatalog.GetSupportedProfiles(
m_historian,
capabilities,
eventCapabilities);
if (profiles.Count != 15)
{
throw new ServiceResultException(
StatusCodes.BadConfigurationError,
$"The ReferenceServer historian satisfies {profiles.Count} of 15 Server facets.");
}
string[] profileUris = new string[profiles.Count];
var conformanceUnits =
new List<QualifiedName>(
s_baseConformanceUnits.Count + 64);
var seen = new HashSet<QualifiedName>();
foreach (QualifiedName unit in s_baseConformanceUnits)
{
conformanceUnits.Add(unit);
seen.Add(unit);
}
for (int i = 0; i < profiles.Count; i++)
{
HistoricalAccessProfileDescriptor profile = profiles[i];
profileUris[i] = profile.ProfileUri;
foreach (string unitName in
profile.MandatoryConformanceUnits)
{
var unit = new QualifiedName(unitName);
if (seen.Add(unit))
{
conformanceUnits.Add(unit);
}
}
}
foreach (HistoricalAggregateFunctionDescriptor aggregate in
HistoricalAggregateFunctionCatalog.AllFunctions)
{
var unit = new QualifiedName(
aggregate.ServerConformanceUnit);
if (seen.Add(unit))
{
conformanceUnits.Add(unit);
}
}
m_historicalProfiles = profileUris;
m_conformanceUnits = conformanceUnits.ToArrayOf();
}
private static SimpleAttributeOperand CreateHistoricalEventField(
string browseName)
{
return new SimpleAttributeOperand
{
TypeDefinitionId = ObjectTypeIds.BaseEventType,
BrowsePath = [new QualifiedName(browseName)],
AttributeId = Attributes.Value
};
}
/// <summary>
/// Scalar history nodes also get the fixed-date segment with the block of Bad values;
/// it must end before the samples relative to the server start begin.
/// </summary>
private static bool HasFixedHistorySegment(BaseVariableState variable)
{
DateTime fixedEnd = s_fixedHistoryStart.AddSeconds(FixedHistorySampleCount * 10);
return variable.DataType != DataTypeIds.DecimalDataType &&
variable.ValueRank < ValueRanks.OneDimension &&
fixedEnd < DateTime.UtcNow.AddSeconds(-10000);
}
private async Task SeedHistoricalNodeAsync(BaseVariableState variable, CancellationToken cancellationToken)
{
NodeId nodeId = variable.NodeId;
BuiltInType dataType = TypeInfo.GetBuiltInType(variable.DataType);
bool isStructure = variable.DataType == DataTypeIds.DecimalDataType;
bool isMatrix = variable.ValueRank >= ValueRanks.TwoDimensions;
bool isArray = variable.ValueRank == ValueRanks.OneDimension;
DateTime now = DateTime.UtcNow;
var seed = new List<DataValue>(FixedHistorySampleCount + 1001);
if (HasFixedHistorySegment(variable))
{
for (int ii = 0; ii < FixedHistorySampleCount; ii++)
{
DateTime timestamp = s_fixedHistoryStart.AddSeconds(ii * 10);
seed.Add(new DataValue(
CreateHistoricalScalarValue(dataType, ii, s_fixedHistoryStart),
GetFixedHistoryStatusCode(ii),
sourceTimestamp: timestamp.AddMilliseconds(1234),
serverTimestamp: timestamp));
}
}
for (int ii = 1000; ii >= 0; ii--)
{
int value = 1000 - ii;
Variant variant;
if (isStructure)
{
variant = CreateHistoricalStructureValue(value);
}
else if (isMatrix)
{
variant = CreateHistoricalMatrixValue(dataType, value, now);
}
else if (isArray)
{
variant = CreateHistoricalArrayValue(dataType, value, now);
}
else
{
variant = CreateHistoricalScalarValue(dataType, value, now);
}
StatusCode statusCode = GetSeededStatusCode(value);
seed.Add(new DataValue(
variant,
statusCode,
sourceTimestamp: now.AddSeconds(-(ii * 10)).AddMilliseconds(1234),
serverTimestamp: now.AddSeconds(-(ii * 10))));
}
using var opContext = new OperationContext(new RequestHeader(), null, RequestType.HistoryUpdate, RequestLifetime.None);
var systemContext = new ServerSystemContext(Server, opContext);
var historianContext = new HistorianOperationContext(systemContext, opContext, null, HistoryUpdateType.Insert);
_ = await m_historian!.InsertAsync(historianContext, nodeId, seed, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Returns a deterministic status code for a seeded historical value so
/// that the recorded data contains Good, Bad, and Uncertain quality.
/// The pattern repeats every 10 samples: index 7 is Bad, index 9 is
/// Uncertain, and the remaining 8 are Good.
/// </summary>
private static StatusCode GetSeededStatusCode(int sampleIndex)
{
return (sampleIndex % 10) switch
{
7 => StatusCodes.BadDataUnavailable,
9 => StatusCodes.UncertainSubstituteValue,
_ => StatusCodes.Good
};
}
private static Variant CreateHistoricalScalarValue(BuiltInType dataType, int value, DateTime now)
{
return dataType switch
{
BuiltInType.Boolean => Variant.From((value & 1) == 0),
BuiltInType.SByte => Variant.From((sbyte)(value % 100)),
BuiltInType.Byte => Variant.From((byte)(value % 200)),
BuiltInType.Int16 => Variant.From((short)value),
BuiltInType.UInt16 => Variant.From((ushort)value),
BuiltInType.Int32 => Variant.From(value),
BuiltInType.UInt32 => Variant.From((uint)value),
BuiltInType.Int64 => Variant.From((long)value),
BuiltInType.UInt64 => Variant.From((ulong)value),
BuiltInType.Float => Variant.From((float)value),
BuiltInType.Double => Variant.From((double)value),
BuiltInType.String => Variant.From(
value.ToString(CultureInfo.InvariantCulture)),
BuiltInType.DateTime => Variant.From(
new DateTimeUtc(now.AddSeconds(value))),
BuiltInType.Guid => Variant.From(
new Uuid(new Guid(value, 0, 0, new byte[8]))),
BuiltInType.ByteString => Variant.From(
new ByteString(BitConverter.GetBytes(value))),
_ => Variant.From(value)
};
}
private static Variant CreateHistoricalArrayValue(
BuiltInType dataType,
int value,
DateTime now)
{
// Build a small, deterministic one-dimensional array per historical
// sample. Element values derive from the sample index so History Read
// Raw of the array nodes returns self-consistent data for the CTT.
const int length = 5;
return dataType switch
{
BuiltInType.Boolean => Variant.From(
CreateArray(length, i => ((value + i) & 1) == 0).ToArrayOf()),
BuiltInType.SByte => Variant.From(
CreateArray(length, i => (sbyte)((value + i) % 100)).ToArrayOf()),
BuiltInType.Byte => Variant.From(
CreateArray(length, i => (byte)((value + i) % 200)).ToArrayOf()),
BuiltInType.Int16 => Variant.From(
CreateArray(length, i => (short)(value + i)).ToArrayOf()),
BuiltInType.UInt16 => Variant.From(
CreateArray(length, i => (ushort)(value + i)).ToArrayOf()),
BuiltInType.Int32 => Variant.From(
CreateArray(length, i => value + i).ToArrayOf()),
BuiltInType.UInt32 => Variant.From(
CreateArray(length, i => (uint)(value + i)).ToArrayOf()),
BuiltInType.Int64 => Variant.From(
CreateArray(length, i => (long)(value + i)).ToArrayOf()),
BuiltInType.UInt64 => Variant.From(
CreateArray(length, i => (ulong)(value + i)).ToArrayOf()),
BuiltInType.Float => Variant.From(
CreateArray(length, i => (float)(value + i)).ToArrayOf()),
BuiltInType.Double => Variant.From(
CreateArray(length, i => (double)(value + i)).ToArrayOf()),
BuiltInType.String => Variant.From(
CreateArray(length, i => (value + i).ToString(CultureInfo.InvariantCulture)).ToArrayOf()),
BuiltInType.DateTime => Variant.From(
CreateArray(length, i => new DateTimeUtc(now.AddSeconds(value + i))).ToArrayOf()),
BuiltInType.ByteString => Variant.From(
CreateArray(length, i => new ByteString(BitConverter.GetBytes(value + i))).ToArrayOf()),
_ => Variant.From(CreateArray(length, i => value + i).ToArrayOf())
};
}
private static Variant CreateHistoricalMatrixValue(
BuiltInType dataType,
int value,
DateTime now)
{
// Build a small, deterministic two-dimensional array (matrix) per
// historical sample. Element values derive from the row/column
// indexes so History Read Raw of the 2D array nodes returns
// self-consistent multi-dimensional data for the CTT.
const int rows = 2;
const int cols = 2;
return dataType switch
{
BuiltInType.Boolean => Variant.From(
CreateMatrix(rows, cols, (r, c) => ((value + r + c) & 1) == 0)),
BuiltInType.SByte => Variant.From(
CreateMatrix(rows, cols, (r, c) => (sbyte)((value + r + c) % 100))),
BuiltInType.Byte => Variant.From(
CreateMatrix(rows, cols, (r, c) => (byte)((value + r + c) % 200))),
BuiltInType.Int16 => Variant.From(
CreateMatrix(rows, cols, (r, c) => (short)(value + r + c))),
BuiltInType.UInt16 => Variant.From(
CreateMatrix(rows, cols, (r, c) => (ushort)(value + r + c))),
BuiltInType.Int32 => Variant.From(
CreateMatrix(rows, cols, (r, c) => value + r + c)),
BuiltInType.UInt32 => Variant.From(
CreateMatrix(rows, cols, (r, c) => (uint)(value + r + c))),
BuiltInType.Int64 => Variant.From(
CreateMatrix(rows, cols, (r, c) => (long)(value + r + c))),
BuiltInType.UInt64 => Variant.From(
CreateMatrix(rows, cols, (r, c) => (ulong)(value + r + c))),
BuiltInType.Float => Variant.From(
CreateMatrix(rows, cols, (r, c) => (float)(value + r + c))),
BuiltInType.Double => Variant.From(
CreateMatrix(rows, cols, (r, c) => (double)(value + r + c))),
BuiltInType.String => Variant.From(
CreateMatrix(rows, cols, (r, c) => (value + r + c).ToString(CultureInfo.InvariantCulture))),
BuiltInType.DateTime => Variant.From(
CreateMatrix(rows, cols, (r, c) => new DateTimeUtc(now.AddSeconds(value + r + c)))),
BuiltInType.ByteString => Variant.From(
CreateMatrix(rows, cols, (r, c) => new ByteString(BitConverter.GetBytes(value + r + c)))),
_ => Variant.From(CreateMatrix(rows, cols, (r, c) => value + r + c))
};
}
private static Variant CreateHistoricalStructureValue(int value)
{
// Emit a structure (DecimalDataType) value per historical sample so
// the CTT "StructureNodeSupportingHistory" node returns encodeable
// structured data on History Read Raw.
return Variant.FromStructure(new DecimalDataType
{
Scale = 100,
Value = new BigInteger(value).ToByteString()
});
}
private static T[] CreateArray<T>(int length, Func<int, T> factory)
{
var result = new T[length];
for (int i = 0; i < length; i++)
{
result[i] = factory(i);
}
return result;
}
private static MatrixOf<T> CreateMatrix<T>(int rows, int cols, Func<int, int, T> factory)
{
var result = new T[rows, cols];
for (int r = 0; r < rows; r++)
{
for (int c = 0; c < cols; c++)
{
result[r, c] = factory(r, c);
}
}
return result.ToMatrixOf();
}
private static readonly ArrayOf<double> s_doubleArray =
[
9.00001d,
9.0002d,
9.003d,
9.04d,
9.5d,
9.06d,
9.007d,
9.008d,
9.0009d
];
}
/// <summary>
/// Defines log messages for reference node manager variable-write failures.
/// </summary>
internal static partial class ReferenceNodeManagerLog
{
/// <summary>
/// Logs a failure to write the simulation-enabled variable.
/// </summary>
[LoggerMessage(
EventId = QuickstartsServersEventIds.ReferenceNodeManager + 2, Level = LogLevel.Error,
Message = "Error writing Enabled variable.")]
public static partial void ErrorWritingEnabledVariable(this ILogger logger, Exception exception);
/// <summary>
/// Logs a failure to write the simulation interval variable.
/// </summary>
[LoggerMessage(
EventId = QuickstartsServersEventIds.ReferenceNodeManager + 6, Level = LogLevel.Error,
Message = "Error writing Interval variable.")]
public static partial void ErrorWritingIntervalVariable(this ILogger logger, Exception exception);
}
}