Files
UA-.NETStandard/samples/WotCon/AggregationClient/AggregationClientRunner.Demo.cs
Marc Schier e8bec27d5d Complete WoT binding preservation and two-pump aggregation (#4431)
# Description

Complete WoT Binding preservation and the two-pump aggregation workflow
without implicit overlays or multi-source action fan-out.

- Add verified linked-document export and bounded partition merging.
Preserve complete source facts, reject contradictory archival content,
and retain usable readable content when a future native grammar is
unsupported.
- Wire local Methods and Condition events through the existing
injectable binding/runtime infrastructure. Keep local and upstream
identities distinct, translate namespace-bearing values, and bound
occurrence routes by source and generation.
- Wait for asynchronous event-producer readiness before completing
subscription creation, so the first alarm is not lost. Preserve
cancellation, teardown, and startup-failure reporting.
- Import runtime types in inheritance order and attach typed Method
argument Properties. Keep generated Double spelling deterministic across
supported frameworks and remove an AOT-unsafe registry validation
conversion.
- Generate both pumps and their asset/group projections. Expose fifteen
typed readings per pump, separate source-owned controls, and
acknowledge/confirm/reset workflows. Extend the demo script for managed
and NativeAOT execution, bounded failures, and exact-process cleanup.
- Synchronize current-state documentation and specification fixtures.
Compose fixture paths from platform-independent components and emit/pin
LF pump XML so byte-exact regeneration holds on Windows, Linux, and
macOS.
- Keep sample JSON readable with sorted members, two-space indentation,
and LF line endings. Normalize generated filenames to single hyphens
without changing registry resource IDs, document values, logical links,
or dependency ordering; the manifest maps resource IDs to their physical
filenames.

Most changed files are deterministic regenerations of checked-in sample
documents. The demo manifest contains 533 linked resources; the
aggregation server remains generic and has no Pump-specific compiled
model dependency.

No new alarm vocabulary, shelving/suppression/dialog/ConditionRefresh
transport mapping, Azure SDK changes, or complete Pump/OpenUSD parity is
introduced.

## Related Issues

Fixes #4430, which records the approved implementation scope, design
decisions, and acceptance criteria.

Follow-up to #4411. Specification fixtures are pinned to
[OPCF-Members/spec-drafts commit
82620d9](https://github.com/OPCF-Members/spec-drafts/commit/82620d9e313f35386dfc7477f7875cd47d2fd268),
associated with OPCF-Members/spec-drafts#35.

## Validation

- .NET 10 and .NET 8: complete affected converter/schema, WoT
Connectivity, and binding suites passed.
- .NET Framework 4.8: affected libraries, converter/generator, projected
runtime, readiness, and NodeSet-import compatibility runs passed. The
existing filesystem test exceeding the host's legacy path limit passed
through a temporary short-path alias, without changing its assertions.
- All 29 aggregation sample tests passed, including the independent
companion-model oracle.
- Managed/default-port and NativeAOT/alternate-port demo runs completed
with 533 documents, two pumps, thirty Good readings, and all four
pump/source control and alarm workflows.
- Timeout and duplicate-port failure paths were exercised; owned
processes and ports were released.
- Specification artifact/rule checks, byte-identical example mirroring,
statement-digest verification, and changed-file whitespace checks
passed.
- The [Build and Test .NET 10.0 workflow for
278b092](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/34095520510)
passed, including the previously failing Linux/macOS Types and WoT
Connectivity jobs. Portability fixes preserve the existing assertions
and add an explicit LF-output regression.
- After the readability changes in `dc61d032`, all 131 targeted
sample/projected-runtime cases passed on both .NET 10 and .NET Framework
4.8, and all 29 aggregation sample tests passed after rebuilding. All
539 JSON assets meet the formatting and filename rules; the 538 document
JSON values are unchanged, with only physical paths changing in the
manifest. Byte-exact regeneration assertions remain in place.

Local validation was scoped to affected projects rather than the entire
UA.slnx solution. Numeric coverage percentages and remaining CI gates
remain for CI; coverage thresholds were not changed. Existing unrelated
analyzer warnings were left unchanged.

## Checklist

- [ ] 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.
- [ ] 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.
- [x] I have addressed **all** PR feedback received.

Regression and integration tests are included and passed as described
above; the combined tests/coverage checkbox remains open because numeric
coverage was not measured locally.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6a9f41b1-72f0-4c74-bd7f-26fa531e5738
2026-09-07 12:24:14 +00:00

496 lines
26 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.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Client.Alarms;
using Opc.Ua.WotCon.Client;
namespace AggregationClient
{
public static partial class AggregationClientRunner
{
private static async Task<ArrayOf<WotPumpControlResult>> ExerciseControlsAsync(
ManagedSession aggregate,
AggregationClientOptions options,
ArrayOf<WotRegistryDocument> documents,
ArrayOf<WotPumpResult> pumps,
CancellationToken cancellationToken)
{
(List<DemoAction> actions, List<DemoEvent> events) = ReadDemoDeclarations(aggregate, documents);
var results = new List<WotPumpControlResult>();
foreach (string sourceName in s_demoSources)
{
string endpoint = sourceName == "SourceA" ? options.SourceAEndpoint : options.SourceBEndpoint;
var sourceOptions = new AggregationClientOptions
{
AggregationEndpoint = endpoint,
ApplicationName = options.ApplicationName + "." + sourceName,
PkiRoot = options.PkiRoot is null ? null : Path.Combine(options.PkiRoot, sourceName)
};
using IHost sourceHost = BuildHost(sourceOptions);
await sourceHost.StartAsync(cancellationToken).ConfigureAwait(false);
try
{
Func<CancellationToken, Task<ManagedSession>> connect = sourceHost.Services
.GetRequiredService<Func<CancellationToken, Task<ManagedSession>>>();
ManagedSession source = await connect(cancellationToken).ConfigureAwait(false);
await using var sourceLifetime = source.ConfigureAwait(false);
ITelemetryContext telemetry = sourceHost.Services.GetRequiredService<ITelemetryContext>();
for (int pumpIndex = 0; pumpIndex < pumps.Count; pumpIndex++)
{
WotPumpResult pump = pumps[pumpIndex];
string sourceNamespace = "urn:opcfoundation.org:UA:WotAggregation:" + sourceName;
string upstreamPump = $"nsu={sourceNamespace};s={pump.Name}";
DemoAction start = FindManagementAction(actions, endpoint, upstreamPump, "Start");
DemoAction stop = FindManagementAction(actions, endpoint, upstreamPump, "Stop");
DemoAction reset = FindManagementAction(actions, endpoint, upstreamPump, "Reset");
await InvokeDemoActionAsync(aggregate, stop, [], cancellationToken).ConfigureAwait(false);
await RequireBooleanAsync(
source, ResolvePortableId(source, upstreamPump + ".Running"), false, cancellationToken)
.ConfigureAwait(false);
await InvokeDemoActionAsync(aggregate, start, [], cancellationToken).ConfigureAwait(false);
await RequireBooleanAsync(
source, ResolvePortableId(source, upstreamPump + ".Running"), true, cancellationToken)
.ConfigureAwait(false);
DemoEvent alarmEvent = events.Single(item =>
item.Endpoint == endpoint && item.UpstreamNotifier == upstreamPump);
DemoAction acknowledge = actions.Single(item =>
item.ConditionAction == "Acknowledge" && item.EventKey == alarmEvent.Key);
DemoAction confirm = actions.Single(item =>
item.ConditionAction == "Confirm" && item.EventKey == alarmEvent.Key);
string signalPath = sourceName == "SourceA"
? ".Events.SupervisionProcessFluid.Cavitation"
: ".Events.SupervisionPumpOperation.MotorOverheat";
NodeId signal = ResolvePortableId(source, upstreamPump + signalPath);
NodeId condition = ResolvePortableId(source, upstreamPump + signalPath + ".Alarm");
await WriteSignalAsync(source, signal, false, cancellationToken).ConfigureAwait(false);
await CompleteSourceAttentionAsync(source, condition, telemetry, cancellationToken)
.ConfigureAwait(false);
await ExerciseAlarmAsync(
aggregate, source, pump.RootNodeId, signal, condition, alarmEvent,
acknowledge, confirm, reset, cancellationToken).ConfigureAwait(false);
results.Add(new WotPumpControlResult(pump.Name, sourceName));
}
}
finally
{
await sourceHost.StopAsync(CancellationToken.None).ConfigureAwait(false);
}
}
if (results.Count != 4)
{
throw new InvalidOperationException("The demonstration must complete both pumps on both sources.");
}
return new ArrayOf<WotPumpControlResult>(results.ToArray());
}
private static async Task ExerciseAlarmAsync(
ManagedSession aggregate,
ManagedSession source,
NodeId notifier,
NodeId signal,
NodeId condition,
DemoEvent alarm,
DemoAction acknowledge,
DemoAction confirm,
DemoAction reset,
CancellationToken cancellationToken)
{
CreateSubscriptionResponse subscription = await aggregate.CreateSubscriptionAsync(
null, 50, 6000, 10, 0, true, 0, cancellationToken).ConfigureAwait(false);
uint subscriptionId = subscription.SubscriptionId;
try
{
var clauses = new SimpleAttributeOperand[s_alarmFields.Length];
for (int i = 0; i < clauses.Length; i++)
{
clauses[i] = new SimpleAttributeOperand
{
TypeDefinitionId = ObjectTypeIds.AlarmConditionType,
BrowsePath = s_alarmFields[i].Split('/').Select(QualifiedName.From).ToArrayOf(),
AttributeId = Attributes.Value
};
}
var request = new MonitoredItemCreateRequest
{
ItemToMonitor = new ReadValueId { NodeId = notifier, AttributeId = Attributes.EventNotifier },
MonitoringMode = MonitoringMode.Reporting,
RequestedParameters = new MonitoringParameters
{
ClientHandle = 1,
QueueSize = 100,
DiscardOldest = true,
Filter = new ExtensionObject(new EventFilter
{
SelectClauses = new ArrayOf<SimpleAttributeOperand>(clauses)
})
}
};
CreateMonitoredItemsResponse created = await aggregate.CreateMonitoredItemsAsync(
null, subscriptionId, TimestampsToReturn.Both, [request], cancellationToken).ConfigureAwait(false);
if (created.Results.Count != 1 || StatusCode.IsBad(created.Results[0].StatusCode))
{
throw new ServiceResultException(
created.Results.Count == 0 ? StatusCodes.BadUnexpectedError : created.Results[0].StatusCode,
"The aggregate alarm monitored item could not be created.");
}
var pendingAcknowledgements = new List<SubscriptionAcknowledgement>();
await WriteSignalAsync(source, signal, true, cancellationToken).ConfigureAwait(false);
ByteString eventId = await WaitForAlarmAsync(
aggregate, alarm.LocalId, pendingAcknowledgements, true, false, false, true, cancellationToken)
.ConfigureAwait(false);
ArrayOf<Variant> arguments =
[Variant.From(eventId), Variant.From(new LocalizedText("Demo acknowledge"))];
await InvokeDemoActionAsync(aggregate, acknowledge, arguments, cancellationToken).ConfigureAwait(false);
eventId = await WaitForAlarmAsync(
aggregate, alarm.LocalId, pendingAcknowledgements, true, true, false, true, cancellationToken)
.ConfigureAwait(false);
await RequireStateAsync(source, condition, "AckedState/Id", true, cancellationToken)
.ConfigureAwait(false);
arguments = [Variant.From(eventId), Variant.From(new LocalizedText("Demo confirm"))];
await InvokeDemoActionAsync(aggregate, confirm, arguments, cancellationToken).ConfigureAwait(false);
_ = await WaitForAlarmAsync(
aggregate, alarm.LocalId, pendingAcknowledgements, true, true, true, true, cancellationToken)
.ConfigureAwait(false);
await RequireStateAsync(source, condition, "ConfirmedState/Id", true, cancellationToken)
.ConfigureAwait(false);
await InvokeDemoActionAsync(aggregate, reset, [], cancellationToken).ConfigureAwait(false);
_ = await WaitForAlarmAsync(
aggregate, alarm.LocalId, pendingAcknowledgements, false, true, true, false, cancellationToken)
.ConfigureAwait(false);
await RequireBooleanAsync(source, signal, false, cancellationToken).ConfigureAwait(false);
await RequireStateAsync(source, condition, "Retain", false, cancellationToken).ConfigureAwait(false);
}
finally
{
await DeleteDemoSubscriptionAsync(aggregate, subscriptionId).ConfigureAwait(false);
}
}
private static async Task DeleteDemoSubscriptionAsync(ManagedSession session, uint subscriptionId)
{
DeleteSubscriptionsResponse deleted = await session.DeleteSubscriptionsAsync(
null, [subscriptionId], CancellationToken.None).ConfigureAwait(false);
if (deleted.Results.Count != 1 || StatusCode.IsBad(deleted.Results[0]))
{
throw new ServiceResultException(
deleted.Results.Count == 0 ? StatusCodes.BadUnexpectedError : deleted.Results[0],
"The demonstration subscription could not be removed.");
}
}
private static async Task<ByteString> WaitForAlarmAsync(
ManagedSession session,
NodeId eventType,
List<SubscriptionAcknowledgement> acknowledgements,
bool active,
bool acknowledged,
bool confirmed,
bool retained,
CancellationToken cancellationToken)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
while (true)
{
PublishResponse response = await session.PublishAsync(
null, new ArrayOf<SubscriptionAcknowledgement>(acknowledgements.ToArray()), timeout.Token)
.ConfigureAwait(false);
acknowledgements.Clear();
if (!response.NotificationMessage.NotificationData.IsEmpty)
{
acknowledgements.Add(new SubscriptionAcknowledgement
{
SubscriptionId = response.SubscriptionId,
SequenceNumber = response.NotificationMessage.SequenceNumber
});
}
foreach (ExtensionObject data in response.NotificationMessage.NotificationData)
{
if (!data.TryGetValue(out EventNotificationList? notifications))
{
continue;
}
foreach (EventFieldList item in notifications.Events)
{
ArrayOf<Variant> fields = item.EventFields;
if (fields.Count != s_alarmFields.Length ||
!fields[1].TryGetValue(out NodeId receivedType) || receivedType != eventType)
{
continue;
}
if (!fields[0].TryGetValue(out ByteString eventId) || eventId.IsEmpty ||
!fields[2].TryGetValue(out bool isActive) ||
!fields[3].TryGetValue(out bool isAcknowledged) ||
!fields[4].TryGetValue(out bool isConfirmed) ||
!fields[5].TryGetValue(out bool isRetained))
{
throw new ServiceResultException(
StatusCodes.BadDecodingError, "The aggregate omitted selected alarm state fields.");
}
if (isActive == active && isAcknowledged == acknowledged &&
isConfirmed == confirmed && isRetained == retained)
{
return eventId;
}
}
}
}
}
private static async Task CompleteSourceAttentionAsync(
ManagedSession source, NodeId condition, ITelemetryContext telemetry, CancellationToken cancellationToken)
{
AlarmClient client = source.GetAlarmClient(telemetry);
if (!await ReadStateAsync(source, condition, "AckedState/Id", cancellationToken).ConfigureAwait(false))
{
await client.AcknowledgeAsync(
condition, await ReadOccurrenceAsync(source, condition, cancellationToken).ConfigureAwait(false),
LocalizedText.Null, cancellationToken).ConfigureAwait(false);
}
if (!await ReadStateAsync(source, condition, "ConfirmedState/Id", cancellationToken).ConfigureAwait(false))
{
await client.ConfirmAsync(
condition, await ReadOccurrenceAsync(source, condition, cancellationToken).ConfigureAwait(false),
LocalizedText.Null, cancellationToken).ConfigureAwait(false);
}
}
private static async Task<ByteString> ReadOccurrenceAsync(
ManagedSession session, NodeId condition, CancellationToken cancellationToken)
{
NodeId node = await ResolveChildAsync(session, condition, "EventId", cancellationToken)
.ConfigureAwait(false);
DataValue value = await session.ReadValueAsync(node, cancellationToken).ConfigureAwait(false);
if (StatusCode.IsBad(value.StatusCode) || !value.WrappedValue.TryGetValue(out ByteString eventId))
{
throw new ServiceResultException(StatusCodes.BadDecodingError, "The source returned no EventId.");
}
return eventId;
}
private static async Task RequireStateAsync(
ManagedSession session, NodeId condition, string path, bool expected, CancellationToken cancellationToken)
{
bool state = await ReadStateAsync(session, condition, path, cancellationToken).ConfigureAwait(false);
if (state != expected)
{
throw new InvalidOperationException($"Source {condition}/{path} did not become {expected}.");
}
}
private static async Task<bool> ReadStateAsync(
ManagedSession session, NodeId condition, string path, CancellationToken cancellationToken)
{
NodeId node = await ResolveChildAsync(session, condition, path, cancellationToken).ConfigureAwait(false);
DataValue value = await session.ReadValueAsync(node, cancellationToken).ConfigureAwait(false);
if (StatusCode.IsBad(value.StatusCode) || !value.WrappedValue.TryGetValue(out bool state))
{
throw new ServiceResultException(StatusCodes.BadDecodingError, $"The source returned no {path} state.");
}
return state;
}
private static async Task<NodeId> ResolveChildAsync(
ManagedSession session, NodeId parent, string path, CancellationToken cancellationToken)
{
var browsePath = new BrowsePath
{
StartingNode = parent,
RelativePath = RelativePath.Parse(path, session.TypeTree)
};
TranslateBrowsePathsToNodeIdsResponse response = await session.TranslateBrowsePathsToNodeIdsAsync(
null, [browsePath], cancellationToken).ConfigureAwait(false);
if (response.Results.Count != 1 || StatusCode.IsBad(response.Results[0].StatusCode) ||
response.Results[0].Targets.Count != 1)
{
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown, $"The source has no {parent}/{path}.");
}
return ExpandedNodeId.ToNodeId(response.Results[0].Targets[0].TargetId, session.NamespaceUris);
}
private static async Task RequireBooleanAsync(
ManagedSession session, NodeId node, bool expected, CancellationToken cancellationToken)
{
DataValue value = await session.ReadValueAsync(node, cancellationToken).ConfigureAwait(false);
if (StatusCode.IsBad(value.StatusCode) ||
!value.WrappedValue.TryGetValue(out bool actual) || actual != expected)
{
throw new InvalidOperationException($"Source {node} did not become {expected}.");
}
}
private static async Task WriteSignalAsync(
ManagedSession session, NodeId nodeId, bool value, CancellationToken cancellationToken)
{
var write = new WriteValue
{
NodeId = nodeId,
AttributeId = Attributes.Value,
Value = new DataValue(Variant.From(value))
};
WriteResponse response = await session.WriteAsync(null, [write], cancellationToken).ConfigureAwait(false);
if (response.Results.Count != 1 || StatusCode.IsBad(response.Results[0]))
{
throw new ServiceResultException(
response.Results.Count == 0 ? StatusCodes.BadUnexpectedError : response.Results[0],
"The source alarm signal could not be changed.");
}
}
private static async Task InvokeDemoActionAsync(
ManagedSession session, DemoAction action, ArrayOf<Variant> inputs, CancellationToken cancellationToken)
{
var browser = new Browser(session)
{
BrowseDirection = BrowseDirection.Inverse,
ReferenceTypeId = ReferenceTypeIds.HasComponent,
IncludeSubtypes = true
};
ArrayOf<ReferenceDescription> parents = await browser.BrowseAsync(
action.LocalId, cancellationToken).ConfigureAwait(false);
if (parents.Count != 1)
{
throw new ServiceResultException(
StatusCodes.BadNodeIdUnknown, $"Action {action.LocalId} has no unique local owner.");
}
NodeId parent = ExpandedNodeId.ToNodeId(parents[0].NodeId, session.NamespaceUris);
_ = await session.CallAsync(parent, action.LocalId, cancellationToken, inputs.ToArray() ?? [])
.ConfigureAwait(false);
}
private static DemoAction FindManagementAction(
List<DemoAction> actions, string endpoint, string pump, string method)
{
return actions.Single(action =>
action.Endpoint == endpoint && action.UpstreamOwner == pump &&
action.UpstreamMethod == pump + "." + method);
}
private static (List<DemoAction> Actions, List<DemoEvent> Events) ReadDemoDeclarations(
ManagedSession session, ArrayOf<WotRegistryDocument> documents)
{
var actions = new List<DemoAction>();
var events = new List<DemoEvent>();
foreach (WotRegistryDocument document in documents)
{
using JsonDocument json = JsonDocument.Parse(document.Content.Memory);
JsonElement root = json.RootElement;
if (root.TryGetProperty("actions", out JsonElement actionMap))
{
foreach (JsonProperty property in actionMap.EnumerateObject())
{
JsonElement action = property.Value;
if (!action.TryGetProperty("forms", out JsonElement forms))
{
continue;
}
if (forms.GetArrayLength() != 1)
{
throw new InvalidDataException($"Demo action {property.Name} must have one source owner.");
}
JsonElement form = forms[0];
string? actsOn = action.TryGetProperty("uav:actsOn", out JsonElement target)
? target.GetString() : null;
actions.Add(new DemoAction(
ResolvePortableId(session, action.GetProperty("uav:id").GetString()!),
form.GetProperty("href").GetString()!,
form.GetProperty("uav:componentOf").GetString()!,
form.GetProperty("uav:id").GetString()!,
action.TryGetProperty("uav:conditionAction", out JsonElement kind)
? kind.GetString() : null,
actsOn is null ? null : document.ResourceId + "#" + actsOn));
}
}
if (root.TryGetProperty("events", out JsonElement eventMap))
{
foreach (JsonProperty property in eventMap.EnumerateObject())
{
JsonElement eventValue = property.Value;
if (!eventValue.TryGetProperty("forms", out JsonElement forms))
{
continue;
}
if (forms.GetArrayLength() != 1)
{
throw new InvalidDataException($"Demo event {property.Name} must have one source owner.");
}
events.Add(new DemoEvent(
document.ResourceId + "#" + property.Name,
ResolvePortableId(session, eventValue.GetProperty("uav:id").GetString()!),
forms[0].GetProperty("href").GetString()!,
forms[0].GetProperty("uav:id").GetString()!));
}
}
}
return (actions, events);
}
private static NodeId ResolvePortableId(ManagedSession session, string value)
{
NodeId result = ExpandedNodeId.ToNodeId(ExpandedNodeId.Parse(value), session.NamespaceUris);
if (result.IsNull)
{
throw new ServiceResultException(StatusCodes.BadNodeIdInvalid, $"Could not resolve '{value}'.");
}
return result;
}
private sealed record DemoAction(
NodeId LocalId,
string Endpoint,
string UpstreamOwner,
string UpstreamMethod,
string? ConditionAction,
string? EventKey);
private sealed record DemoEvent(string Key, NodeId LocalId, string Endpoint, string UpstreamNotifier);
private static readonly string[] s_demoSources = ["SourceA", "SourceB"];
private static readonly string[] s_alarmFields =
["EventId", "EventType", "ActiveState/Id", "AckedState/Id", "ConfirmedState/Id", "Retain"];
}
}