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

259 lines
9.6 KiB
Markdown

# 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](AlarmsAndConditions.md)).
For long-lived application subscriptions, the callback-based
`ISubscriptionManager.Add` + `ISubscriptionNotificationHandler` API
remains the right choice (see
[Sessions.md](Sessions.md) §4.2 *V2 subscription manager*).
- [Quick reference](#quick-reference)
- [Obtaining an `IStreamingSubscription`](#obtaining-an-istreamingsubscription)
- [Subscribing to data changes](#subscribing-to-data-changes)
- [Subscribing to events](#subscribing-to-events)
- [Composing streams](#composing-streams)
- [Lifecycle, cancellation, disposal](#lifecycle-cancellation-disposal)
- [Pairing with `AlarmClient`](#pairing-with-alarmclient)
- [Comparison with classic and V2 APIs](#comparison-with-classic-and-v2-apis)
## 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.
```csharp
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:
```csharp
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
```csharp
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:
```csharp
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
```csharp
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.
```csharp
// 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`.
```csharp
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.).
```csharp
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](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](Sessions.md)
- Reference client sample:
`Applications/ConsoleReferenceClient/AlarmClientSample.cs`