Files
UA-.NETStandard/Docs/StreamingSubscription.md
Marc Schier 01d7162313 Part 9 Alarms & Conditions: complete server + client implementation (#3805)
Complete implementation of **OPC UA Part 9 ΓÇö Alarms & Conditions** for
both server and client, **OPC UA Part 16 ΓÇö State Machines** (generic
extensible client + server API), and the supporting infrastructure
(source-generated event records, source-generated ObjectType proxies,
`IAsyncEnumerable` streaming subscriptions, address-space model change
tracking, per-node `INodeCache` invalidation, unified DI integration).

### Server-side (Part 9 complete)

- **`AlarmConditionState`** ΓÇö every Part 9 method wired automatically
in `OnAfterCreate`: `Silence`, `Suppress`/`Suppress2`,
`Unsuppress`/`Unsuppress2`, `RemoveFromService`/`RemoveFromService2`,
`PlaceInService`/`PlaceInService2`, `Reset`/`Reset2`,
`GetGroupMemberships`. Each method generates the spec-mandated audit
event and supports an optional veto-delegate hook.
- **Latched alarms** ΓÇö `ActiveState` reflects real process state;
`LatchedState` persists until explicit `Reset` (with full precondition
validation).
- **Re-alarming** ΓÇö
`ProcessReAlarm`/`ResetReAlarmRepeatCount`/`IsReAlarmEnabled` helpers
(opt-in, no hidden timer).
- **Audible alarms** ΓÇö `UpdateAudibleState` for
`AudibleEnabled`/`AudibleSound`/`SilenceState` lifecycle.
- **Suppression + groups** ΓÇö `AlarmGroup`, `AlarmSuppressionEngine`
(with first-in-group), `AlarmRateTracker` (for `AlarmMetricsType`).
- **Spec-compliance fixes** ΓÇö `SuppressedOrShelved` now includes
`OutOfServiceState`; `GetRetainState` includes `LatchedState`;
`EffectiveDisplayName` composition includes `OutOfServiceState` +
`LatchedState`.

### Client-side Part 9 API

- **`AlarmClient`** ΓÇö typed Part 9 operations. Each method delegates
to the matching source-generated `*TypeClient` proxy
(`ConditionTypeClient`, `AcknowledgeableConditionTypeClient`,
`AlarmConditionTypeClient`, `DialogConditionTypeClient`,
`ShelvedStateMachineTypeClient`) ΓÇö one source of truth for every
method NodeId.
- **Interface hierarchy** — `IConditionOperations` →
`IAcknowledgeableConditionOperations` → `IAlarmOperations` +
`IDialogConditionOperations`.
- **Source-generated event records** ΓÇö new `EventRecordGenerator`
(alongside `ObjectTypeProxyGenerator`) emits `{Type}TypeRecord` per OPC
UA event type: `ConditionTypeRecord` →
`AcknowledgeableConditionTypeRecord` → `AlarmConditionTypeRecord` →
`LimitAlarmTypeRecord`/`ExclusiveLimitAlarmTypeRecord`/… all the way
to vendor subtypes. **Vendor alarm types get records automatically.**
- **`AlarmEventDecoder`** ΓÇö decodes `EventNotification.Fields` into
the most-specific generated record.
- **`AlarmEventFilterBuilder`** ΓÇö fluent builder for Part 9
`EventFilter`s.
- **`ISession.GetAlarmClient(telemetry)`** convenience extension.

### Part 16 state machines (generic, extensible)

- **Client**: extension methods on the source-generated
`StateMachineTypeClient` / `FiniteStateMachineTypeClient` ΓÇö
`GetCurrentStateAsync`, `ObserveFiniteTransitionsAsync`
(`IAsyncEnumerable<FiniteStateSnapshot>`), `WaitForStateAsync`,
`GetAvailableStatesAsync` / `GetAvailableTransitionsAsync`. **Vendor
proxies inherit the API automatically** through the generated
inheritance chain.
- **Server**: `StateMachineBuilder` (fluent: `AddState` /
`AddTransition` / `OnCause` / validation) +
`FluentFiniteStateMachineState` ΓÇö declare a Part 16 state machine
without subclassing the abstract base.
- **Alarms alignment**: `AlarmClient.GetShelvingStateAsync` /
`ObserveShelvingTransitionsAsync` surface the API for every alarm's
`ShelvingState` child.

### IAsyncEnumerable streaming subscription

- **`IStreamingSubscription`** with
`SubscribeDataChangesAsync`/`SubscribeEventsAsync` returning
`IAsyncEnumerable<T>` (Channel-backed).
- **`ManagedSession.DefaultStreaming`** ΓÇö lazy shared subscription;
disposed with the session.
- **Helpers** ΓÇö `TakeUntilAsync`, `WithTimeoutAsync`, `TakeAsync`,
`BufferedAsync` for state-machine waits and bounded observation windows.
- **`AlarmStreamExtensions`** ΓÇö
`SubscribeAlarmsAsync`/`SubscribeConditionsAsync`/`SubscribeDialogsAsync`
returning the source-generated record types.

### Address-space model change tracking

- **Server**: `RaiseGeneralModelChangeEvent` on both `CustomNodeManager`
and `AsyncCustomNodeManager`; `ModelChangeAggregator` (in
`Opc.Ua.Server.NodeManager`, used by every node manager) batches changes
per service call; **auto-emit** from `CreateNode`/`DeleteNode` (opt-out
via `ModelChangeEmissionEnabled = false`).
- **Client**: `IModelChangeTracker` subscribes to
`BaseModelChangeEventType` via the streaming subscription, decodes
`ModelChangeStructureDataType` payloads, invalidates the `INodeCache`
per-node.
- **`INodeCache.InvalidateNode(NodeId)`** ΓÇö new interface member (true
per-node eviction in `NodeCache`).
- **`ManagedSession.EnableModelChangeTrackingAsync()`** +
`ManagedSessionOptions.ModelChangeTracking` +
`ManagedSessionBuilder.WithModelChangeTracking()` fluent method.

### Dependency Injection integration

- **`services.AddOpcUa().AddClient(...).AddAlarms()`** ΓÇö registers
`AlarmClientFactory` so DI-hosted apps can resolve a Part 9 client per
session without `new`-ing one manually.
- Cross-link added to the unified DI surface (`AddServer` / `AddClient`
/ `AddComplexTypes` / `AddGdsServer` / `AddLdsServer` /
`AddWotConServer`).

### Quickstart server modernization

- `Quickstarts.Servers/Alarms/AlarmNodeManager.cs` ported to
`AsyncCustomNodeManager` and demonstrates the new `AlarmGroup` +
`AlarmSuppressionEngine` end-to-end via a writable `MaintenanceMode`
boolean that suppresses every member alarm.

### Documentation

- New: `Docs/AlarmsAndConditions.md`, `Docs/StreamingSubscription.md`,
`Docs/ModelChangeTracking.md`, `Docs/StateMachines.md` ΓÇö full
developer guides.
- Updated: `Docs/DependencyInjection.md` (`AddAlarms()` row + section),
`Docs/README.md` index, `Docs/MigrationGuide.md` (Part 9 / Part 16 /
model change tracking sections covering behavior changes + breaking API
surface).
- Sample: `Applications/ConsoleReferenceClient/AlarmClientSample.cs`.
2026-05-29 23:24:53 +02:00

9.6 KiB

Streaming Subscriptions

IStreamingSubscription exposes OPC UA subscription notifications as IAsyncEnumerable<T> streams. Each SubscribeXxxAsync call adds a monitored item to a shared, lazy-created OPC UA subscription and pipes notifications through a System.Threading.Channels.Channel<T>. Disposing the enumerator removes the monitored item.

This API sits on top of the V2 subscription engine (Libraries/Opc.Ua.Client/Subscription). It is not a replacement for either the classic or V2 callback-based API — it is a thin abstraction targeted at three concrete client scenarios:

  • State-machine waits — "subscribe, observe transitions until X, unsubscribe".
  • Short-lived monitoring — "I need a sample for 30 seconds".
  • Typed alarm streaming — Part 9 alarm records via AlarmStreamExtensions (see AlarmsAndConditions.md).

For long-lived application subscriptions, the callback-based ISubscriptionManager.Add + ISubscriptionNotificationHandler API remains the right choice (see Sessions.md §4.2 V2 subscription manager).

Quick reference

Concern API
Get a streaming subscription ManagedSession.DefaultStreaming
Construct one manually new StreamingSubscription(subscriptionManager, options?)
Subscribe to one variable SubscribeDataChangesAsync(nodeId, options?, ct)
Subscribe to many variables SubscribeDataChangesAsync(IReadOnlyList<NodeId>, ...)
Subscribe to events SubscribeEventsAsync(notifierId, EventFilter, options?, ct)
Wait until predicate matches .TakeUntilAsync(predicate)
Cap by wall-clock time .WithTimeoutAsync(timeout)
Take N items .TakeAsync(count)
Buffer first N into a list .BufferedAsync(count)
Typed alarm stream streaming.SubscribeAlarmsAsync(notifierId, filter?)

Obtaining an IStreamingSubscription

ManagedSession.DefaultStreaming lazily constructs a shared instance the first time it is accessed and disposes it when the session is disposed.

ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
    .UseEndpoint(endpoint)
    .ConnectAsync(ct);

IStreamingSubscription streaming = session.DefaultStreaming;

If you need to bind to a specific ISubscriptionManager (for example in tests or with the raw Session's V2 engine), construct directly:

var streaming = new StreamingSubscription(
    subscriptionManager: session.SubscriptionManager,
    subscriptionOptions: new SubscriptionOptions
    {
        PublishingInterval = TimeSpan.FromMilliseconds(250),
        KeepAliveCount = 10,
        LifetimeCount = 100,
    });

The underlying OPC UA subscription is not created until the first SubscribeXxxAsync call. Once created it is shared by every active SubscribeXxxAsync enumerator on this StreamingSubscription.

Subscribing to data changes

await foreach (DataValueChange change in streaming
    .SubscribeDataChangesAsync(VariableIds.Server_ServerStatus_CurrentTime, ct: ct)
    .ConfigureAwait(false))
{
    Console.WriteLine($"{change.MonitoredItem?.Name}: {change.Value}");
}

For multiple variables in a single stream:

var nodes = new[]
{
    VariableIds.Server_ServerStatus_CurrentTime,
    VariableIds.Server_ServerStatus_SecondsTillShutdown,
};

await foreach (DataValueChange change in streaming
    .SubscribeDataChangesAsync(nodes, options: new MonitoredItemOptions
    {
        SamplingInterval = TimeSpan.FromMilliseconds(500),
        QueueSize = 5,
    }, ct: ct).ConfigureAwait(false))
{
    Console.WriteLine($"{change.MonitoredItem?.ClientHandle}: {change.Value}");
}

Each MonitoredItemOptions is treated as a template; only StartNodeId is overridden internally per node.

Subscribing to events

EventFilter filter = new AlarmEventFilterBuilder()
    .ForAlarms()
    .Build();

await foreach (EventNotification evt in streaming
    .SubscribeEventsAsync(ObjectIds.Server, filter, ct: ct)
    .ConfigureAwait(false))
{
    // Raw access; for typed records use SubscribeAlarmsAsync.
    Console.WriteLine($"#{evt.Fields.Count} fields");
}

SubscribeEventsAsync does not validate the filter. Use AlarmEventFilterBuilder for Part 9 events and any custom SimpleAttributeOperand-based filter for other event types.

Composing streams

StreamingSubscriptionExtensions ships four LINQ-style helpers designed for the bounded-observation use cases. They wrap the source IAsyncEnumerable<T> and stop iterating (which disposes the underlying enumerator and the monitored item) when their condition fires.

// 1) Wait until a predicate matches, then return the matching item.
DataValueChange completed = await streaming
    .SubscribeDataChangesAsync(buildProgressVariable)
    .TakeUntilAsync(c => (double)c.Value.Value >= 100.0)
    .LastAsync(ct);

// 2) Cap by wall-clock time. Completes silently when the timeout elapses.
List<DataValueChange> samples = await streaming
    .SubscribeDataChangesAsync(sensor)
    .WithTimeoutAsync(TimeSpan.FromSeconds(30))
    .ToListAsync(ct);

// 3) Take exactly N values.
List<DataValueChange> firstThree = await streaming
    .SubscribeDataChangesAsync(sensor)
    .TakeAsync(3)
    .ToListAsync(ct);

// 4) Buffer N items into a list — for one-shot snapshots.
IReadOnlyList<DataValueChange> snapshot = await streaming
    .SubscribeDataChangesAsync(sensor)
    .BufferedAsync(count: 10, ct);

TakeUntilAsync yields the matching item last, so callers can grab the transition value with LastAsync / a terminating handler.

Lifecycle, cancellation, disposal

The streaming subscription guarantees three invariants:

  1. Lazy subscription creation. No OPC UA CreateSubscription round-trip happens until your first SubscribeXxxAsync call.
  2. Reference-counted monitored items. Each call adds a monitored item; disposing the enumerator (end of the await foreach, an exception, or explicit await using) removes that monitored item. The underlying subscription stays alive for other enumerators.
  3. Disposal order. Disposing the StreamingSubscription completes all open channels and deletes the underlying OPC UA subscription. Disposing the ManagedSession calls this for you.

Cancellation propagates the natural way: pass a CancellationToken to SubscribeXxxAsync or the outer await foreach (via WithCancellation). When the token fires the enumerator stops, the finally block removes the monitored item, and the loop body throws OperationCanceledException.

using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
try
{
    await foreach (DataValueChange change in
        streaming.SubscribeDataChangesAsync(sensor, ct: cts.Token))
    {
        Process(change);
    }
}
catch (OperationCanceledException) { /* expected */ }

Pairing with AlarmClient

The streaming subscription and AlarmClient are complementary:

  • IStreamingSubscription delivers the events (raw or typed).
  • AlarmClient calls the methods (acknowledge, shelve, suppress, reset, etc.).
AlarmClient alarms = session.GetAlarmClient();
IStreamingSubscription streaming = session.DefaultStreaming;

await foreach (ConditionTypeRecord rec in streaming
    .SubscribeAlarmsAsync(ObjectIds.Server)
    .TakeUntilAsync(r =>
        r is AlarmConditionTypeRecord a &&
        a.ConditionId == myAlarmId &&
        a.ActiveStateId == true))
{
    if (rec is AlarmConditionTypeRecord active && active.ActiveStateId == true)
    {
        await alarms.AcknowledgeAsync(
            active.ConditionId,
            active.EventId,
            new LocalizedText("en", "Auto-acked")).ConfigureAwait(false);
    }
}

See AlarmsAndConditions.md for the typed record hierarchy.

Comparison with classic and V2 APIs

Aspect Classic Subscription V2 ISubscriptionManager IStreamingSubscription
Notification delivery MonitoredItem.Notification event ISubscriptionNotificationHandler callback IAsyncEnumerable<T>
Add monitored item subscription.AddItem(item); ApplyChanges() subscription.MonitoredItems.TryAdd(name, options, out _) streaming.SubscribeXxxAsync(...)
Remove monitored item subscription.RemoveItem(item); ApplyChanges() subscription.MonitoredItems.TryRemove(handle) Dispose enumerator
Cancellation Manual Per call (CT in handler) Built-in (CT on stream)
Composition Hand-rolled Hand-rolled LINQ-style helpers
Backpressure Custom logic Custom logic Bounded channel internally
Best for Legacy callers Long-lived, multi-item subscriptions Short-lived / wait-for-X scenarios

There is no migration cost — all three APIs can coexist on the same session. Pick the one that matches the use case.

Reference

  • Source: Libraries/Opc.Ua.Client/Subscription/Streaming/
  • Helpers source: Libraries/Opc.Ua.Client/Subscription/Streaming/StreamingSubscriptionExtensions.cs
  • Alarm streaming: Libraries/Opc.Ua.Client/Alarms/AlarmStreamExtensions.cs
  • Sessions architecture: Sessions.md
  • Reference client sample: Applications/ConsoleReferenceClient/AlarmClientSample.cs