Merge b640-641: native alarm state survives rebuild; projector pruned; AbCip ConditionId=RawPath (#641)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -1145,10 +1145,15 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
|
||||
active nativeCode (ties: ordinal nativeCode, then type); ack is server-side only. ⚠️ UNAVAILABLE never clears: an
|
||||
active condition stays active+acked with a quality-only `BadNoCommunication` (same as #481 transport loss). The
|
||||
driver dedups against what the server was last TOLD (level+nativeCode+nativeSeverity+qualifier — not text), records
|
||||
only while a handler is attached, and clears that record when the subscribed alarm SET changes (a rebuild recreates
|
||||
the nodes inactive) but not on a reconnect of the same set — a replayed `/current` must never re-raise (un-ack).
|
||||
only while a handler is attached, and drops the record of only the conditions that LEFT the set when the subscribed
|
||||
alarm SET changes (#641) — never on a reconnect of the same set; a replayed `/current` must never re-raise (un-ack).
|
||||
`AlarmEventArgs` gained optional `OpcUaSeverity` (exact Part 9 severity) and `QualityStatusCode`; a quality-only
|
||||
event goes out as `AlarmQualityUpdate` per node and never through the snapshot projection.
|
||||
- **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's
|
||||
live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply`
|
||||
(called by `OpcUaPublishActor` at the end of every apply) reports ONE event per restored off-normal condition and
|
||||
discards unclaimed captures. `NativeAlarmProjector` is pruned per apply, not cleared. Native routing is an exact
|
||||
`ConditionId == RawPath` match — AbCip's `#active` suffix was fixed; FOCAS is still unroutable (`docs/drivers/FOCAS.md`).
|
||||
- **Driver removal (#609):** removing a driver releases its capability-invoker pipelines and resilience
|
||||
tracker, *then* evicts its telemetry snapshots. ⚠️ Both are **tombstoned**, not just cleared: the removed
|
||||
child stops asynchronously and its in-flight subscribe/connect still completes through the invoker (and
|
||||
|
||||
@@ -54,6 +54,58 @@ same condition is wired as an **event notifier of every referencing equipment fo
|
||||
- The `alerts`-topic `AlarmTransitionEvent` carries the (possibly empty) **list of referencing
|
||||
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
|
||||
identity RawPath + condition NodeId) with the equipment list as display metadata.
|
||||
- **Routing is an exact `ConditionId == RawPath` match** (`DriverHostActor.ForwardNativeAlarm`). A driver that
|
||||
decorates the id has every transition dropped as "no alarm condition" — AbCip did (`"{ref}#active"`, fixed
|
||||
under #641); FOCAS still does (see `drivers/FOCAS.md`). A native source must also set `Kind`: an
|
||||
`Unspecified` kind projects no state change.
|
||||
|
||||
### Native condition state survives a full rebuild (Gitea #641)
|
||||
|
||||
A full `RebuildAddressSpace` recreates every condition inactive + acknowledged. For **scripted** alarms the
|
||||
engine re-asserts every non-normal condition after the reload (#487). **Native** conditions had nothing
|
||||
equivalent: the host re-subscribes a driver's alarms only when its alarm-ref *set* changes, and a
|
||||
transition-only source (Galaxy, OpcUaClient) has nothing to replay anyway — so a rebuild that left the alarm
|
||||
set unchanged (adding one `UnsTagReference` to an alarm-bearing raw tag is enough to force one, as are
|
||||
`ChangedEquipment`, a scripted-alarm edit, a raw container rename, an alarm severity/type edit, or any
|
||||
surgical fallback to `SafeRebuild()`) left every active native alarm reading **normal** until its next device
|
||||
transition.
|
||||
|
||||
The node manager now carries native state across the rebuild itself:
|
||||
|
||||
1. `RebuildAddressSpace` captures each **native** condition's live state off the **node** — Active, Acked,
|
||||
Confirmed, Severity, Message, Comment + ClientUserId, Quality, Time, `ActiveState.TransitionTime` — before clearing. Reading the
|
||||
node, not a host-side cache, is what keeps a **client's** acknowledgement: the SDK applies it to the node
|
||||
and neither the driver nor `NativeAlarmProjector` ever sees it.
|
||||
2. `MaterialiseAlarmCondition(isNative: true)` for the same id in the same apply restores it. **Quality is
|
||||
always restored** (it is otherwise re-annotated only on a connectivity edge, so even a normal condition
|
||||
would sit at `BadWaitingForInitialData`); the rest only when the condition was off-normal (active or
|
||||
unacknowledged). A normal condition keeps what the materialise built — message and authored severity
|
||||
included — exactly the #487 rule. Shelving/Enabled are not carried (native conditions refuse them, #587).
|
||||
3. `IOpcUaAddressSpaceSink.CompleteAddressSpaceApply`, which `OpcUaPublishActor` calls at the end of
|
||||
**every** apply (in a `finally`), reports **one** condition event per restored condition — after the
|
||||
notifier wiring, so the raw folder, every referencing equipment folder and `i=2253` items all get it,
|
||||
stamped with the original `Time`, not the deploy time (or the node's newer `Time`, if a client method call
|
||||
reported in the restore→complete window — that window can yield a second, state-correct event) — and **discards** every capture no materialise
|
||||
claimed, so a later deploy re-adding the same RawPath gets a fresh node (no resurrection). A kind swap
|
||||
(native ↔ scripted on one id) is never restored. Node-only: no `/alerts` row, no historian record.
|
||||
|
||||
Two companions keep the host and driver in step with the carried node:
|
||||
|
||||
- `DriverHostActor` **prunes** `NativeAlarmProjector` to the still-deployed conditions on each apply
|
||||
(`RetainOnly`) instead of clearing it. Clearing made the next driver Acknowledge of an active alarm
|
||||
project `Active=false`, and the next Clear of an unacknowledged one project `Acked=true` — after *any*
|
||||
deploy, rebuild or not.
|
||||
- MTConnect's "last told" record is pruned to the conditions that **left** the subscribed set rather than
|
||||
cleared on any set change; re-raising a surviving (carried) condition would un-acknowledge it. AbCip's ALMD
|
||||
last-seen `InFaulted` record is likewise driver-owned and pruned to the set, so a re-subscribe (every
|
||||
reconnect / set change) does not re-raise a still-faulted alarm.
|
||||
- A routed **client** acknowledgement marks the projector's prior state acknowledged
|
||||
(`NativeAlarmProjector.MarkAcknowledged`), so a later driver Clear does not project it unacknowledged.
|
||||
|
||||
Accepted residual: a condition that clears at the device while the rebuild runs is restored active until
|
||||
the driver's Clear arrives — which it does, because the driver's own record still holds it active. The capture
|
||||
is keyed by RawPath only, so a tag deleted and re-created under the same RawPath in one deploy inherits the old
|
||||
condition's state until its next transition.
|
||||
|
||||
## Condition event identity fields (what a client reads on the wire)
|
||||
|
||||
|
||||
@@ -264,6 +264,10 @@ analog alarms are a follow-up) and **disabled by default** — shops running FT
|
||||
Alarm & Events should keep it off and take alarms through the native route, since
|
||||
the projection semantics don't exactly mirror Rockwell FT A&E.
|
||||
|
||||
Each event carries `ConditionId` = the subscribed reference (the alarm tag's RawPath — the key the host
|
||||
routes native transitions on) and `Kind` = `Raise` / `Clear`. Before Gitea #641 the projection sent
|
||||
`"{ref}#active"` with no kind, so the host dropped every ALMD transition as unrouted.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` cover the driver, host-address parser, UDT planner, and alarm projection via fake tag runtimes.
|
||||
|
||||
@@ -363,6 +363,13 @@ Every alarm transition fires `OnAlarmEvent` with:
|
||||
Cleared alarms fire a second event with `" (cleared)"` appended to the message
|
||||
so downstream consumers can ignore the clear if they only care about raises.
|
||||
|
||||
> ⚠️ **Not routable to a v3 raw condition (found under Gitea #641).** The server routes a native transition
|
||||
> only when `ConditionId` equals an alarm-bearing raw tag's RawPath, and the host subscribes those RawPaths —
|
||||
> but this projection filters devices by **host address** (so a RawPath list matches no device and nothing is
|
||||
> polled) and emits `"{host}#{Type}:{AlarmNumber}"` with no `Kind`. FOCAS native alarms therefore reach no
|
||||
> Part 9 condition today. Fixing it needs a mapping decision (the CNC exposes one flat alarm list per device,
|
||||
> not one condition per tag), so it is tracked separately rather than patched.
|
||||
|
||||
## Handle recycling
|
||||
|
||||
FANUC CNCs have a finite FWLIB handle pool (~5–10 concurrent connections) and
|
||||
|
||||
@@ -475,11 +475,12 @@ and records a state only when an alarm handler was attached to receive it. Conse
|
||||
condition the driver owns `BadNoCommunication` on the connectivity edge (#477) and keeps its Active
|
||||
state; the reconnect edge re-annotates Good, after which the driver re-asserts any still-`Unavailable`
|
||||
condition's Bad quality on its re-subscribe.
|
||||
- A re-subscribe with a **changed** alarm set clears the whole told record: only an alarm-bearing raw-tag
|
||||
delta changes the set, the server applies that as a full rebuild, and a rebuild recreates every
|
||||
condition node inactive — so every active condition is re-asserted onto its new node. The **same** set
|
||||
(a reconnect) keeps the record. (A rebuild that leaves this driver's alarm set unchanged triggers no
|
||||
re-subscribe; that is a host-wide gap shared with every native source.)
|
||||
- A re-subscribe with a **changed** alarm set drops the told record of only the conditions that **left**
|
||||
the set. The server carries every native condition's state (acknowledgement included) across a full
|
||||
rebuild itself (Gitea #641 — see `docs/AlarmTracking.md`), so re-raising a surviving condition would
|
||||
only un-acknowledge it; a condition that left and later comes back was materialised fresh and is told
|
||||
again. The **same** set (a reconnect) keeps the whole record. A rebuild that leaves the alarm set
|
||||
unchanged triggers no re-subscribe, and needs none — the server-side carry-over covers it.
|
||||
- An alarm-only driver (no value tags) still runs the `/sample` pump.
|
||||
|
||||
**EVENT controlled vocabularies as OPC UA enumerations — not built (parked, low priority).** EVENT values (`Execution`,
|
||||
|
||||
@@ -69,6 +69,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
/// <inheritdoc />
|
||||
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CompleteAddressSpaceApply() => _inner.CompleteAddressSpaceApply();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm);
|
||||
|
||||
|
||||
@@ -185,6 +185,19 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// </summary>
|
||||
void RebuildAddressSpace();
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — mark the END of one address-space apply pass (after <see cref="RebuildAddressSpace"/>, if
|
||||
/// one ran, and every materialise / notifier-wiring call that followed it). An SDK-backed sink carries each
|
||||
/// native condition's live Part 9 state across a rebuild and restores it when the same condition is
|
||||
/// re-materialised; this is where it reports the ONE condition event per restored condition (now that
|
||||
/// every notifier folder is wired) and DISCARDS any captured state no materialise claimed, so a later
|
||||
/// deploy that re-adds the same id gets a fresh node rather than a resurrection. <c>OpcUaPublishActor</c>
|
||||
/// calls it once at the end of every apply, rebuild or not. Idempotent; the default is a no-op.
|
||||
/// </summary>
|
||||
void CompleteAddressSpaceApply()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Announce that nodes were added at runtime (discovered-node injection) under
|
||||
/// <paramref name="affectedNodeId"/> so subscribed clients refresh their browse
|
||||
|
||||
@@ -70,6 +70,12 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
// prior subscriptions before starting the new one is semantically faithful. Snapshotting +
|
||||
// clearing under the same lock DisposeAsync uses guarantees the stale subs can never be
|
||||
// double-owned (and thus never double-disposed) by a racing dispose.
|
||||
// Gitea #641 review — forget only the refs that LEFT the set. A survivor keeps its last-seen InFaulted, so
|
||||
// a re-subscribe (every reconnect, every alarm-set change) does not re-RAISE a still-faulted alarm: the
|
||||
// server carries the condition across a rebuild, and a Raise would un-acknowledge it. A ref that left and
|
||||
// comes back was materialised fresh, so it starts from "not faulted" and is raised again.
|
||||
_driver.AlarmFaultRecord.RetainOnly(sub.SourceNodeIds);
|
||||
|
||||
List<Subscription> stale;
|
||||
lock (_subsLock)
|
||||
{
|
||||
@@ -190,26 +196,31 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
// is intentional and matches the documented behaviour.
|
||||
var severity = ToInt(severityDv.Value);
|
||||
|
||||
var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false);
|
||||
sub.LastInFaulted[nodeId] = nowFaulted;
|
||||
var wasFaulted = _driver.AlarmFaultRecord.Exchange(nodeId, nowFaulted);
|
||||
|
||||
// ConditionId is the subscribed reference itself — the alarm tag's RawPath, the key the host routes
|
||||
// native transitions on — and Kind tells the host's projector what changed (Gitea #641). This used
|
||||
// to send "{ref}#active" with no Kind, so the host dropped every ALMD transition as unrouted (and an
|
||||
// Unspecified kind would have projected no state change anyway).
|
||||
if (!wasFaulted && nowFaulted)
|
||||
{
|
||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
||||
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
|
||||
sub.Handle, nodeId, ConditionId: nodeId,
|
||||
AlarmType: "ALMD",
|
||||
Message: $"ALMD {nodeId} raised",
|
||||
Severity: MapSeverity(severity),
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
SourceTimestampUtc: DateTime.UtcNow,
|
||||
Kind: AlarmTransitionKind.Raise));
|
||||
}
|
||||
else if (wasFaulted && !nowFaulted)
|
||||
{
|
||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
||||
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
|
||||
sub.Handle, nodeId, ConditionId: nodeId,
|
||||
AlarmType: "ALMD",
|
||||
Message: $"ALMD {nodeId} cleared",
|
||||
Severity: MapSeverity(severity),
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
SourceTimestampUtc: DateTime.UtcNow,
|
||||
Kind: AlarmTransitionKind.Clear));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,9 +308,44 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
|
||||
/// <summary>Gets or sets the polling loop task.</summary>
|
||||
public Task Loop { get; set; } = Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the dictionary tracking the last known InFaulted state for each node.</summary>
|
||||
public Dictionary<string, bool> LastInFaulted { get; } = new(StringComparer.Ordinal);
|
||||
/// <summary>
|
||||
/// Gitea #641 review — the last <c>InFaulted</c> the ALMD projection told the server, per alarm RawPath. Owned by
|
||||
/// the <see cref="AbCipDriver"/> rather than a subscription or a projection, so it survives a re-subscribe and a
|
||||
/// re-initialise (both of which happen on every reconnect): the server's condition nodes outlive them, and a
|
||||
/// re-raise there would un-acknowledge. Thread-safe — a retiring poll loop may overlap the new one for a tick.
|
||||
/// </summary>
|
||||
internal sealed class AbCipAlarmFaultRecord
|
||||
{
|
||||
private readonly Dictionary<string, bool> _lastInFaulted = new(StringComparer.Ordinal);
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
/// <summary>Records <paramref name="nowFaulted"/> for <paramref name="nodeId"/>, returning the prior value
|
||||
/// (<c>false</c> when none).</summary>
|
||||
/// <param name="nodeId">The alarm RawPath.</param>
|
||||
/// <param name="nowFaulted">The state just read.</param>
|
||||
/// <returns>The previously recorded state.</returns>
|
||||
public bool Exchange(string nodeId, bool nowFaulted)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var was = _lastInFaulted.GetValueOrDefault(nodeId, false);
|
||||
_lastInFaulted[nodeId] = nowFaulted;
|
||||
return was;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forgets every ref not in <paramref name="nodeIds"/>.</summary>
|
||||
/// <param name="nodeIds">The newly subscribed refs.</param>
|
||||
public void RetainOnly(IReadOnlyList<string> nodeIds)
|
||||
{
|
||||
var keep = new HashSet<string>(nodeIds, StringComparer.Ordinal);
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var k in _lastInFaulted.Keys.Where(k => !keep.Contains(k)).ToList()) _lastInFaulted.Remove(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
private readonly ILogger<AbCipDriver> _logger;
|
||||
private AbCipAlarmProjection _alarmProjection;
|
||||
|
||||
/// <summary>Gitea #641 review — the ALMD projection's last-told InFaulted per alarm ref; outlives every
|
||||
/// projection and subscription (see <see cref="AbCipAlarmFaultRecord"/>).</summary>
|
||||
internal AbCipAlarmFaultRecord AlarmFaultRecord { get; } = new();
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>Occurs when a subscribed tag's value changes.</summary>
|
||||
|
||||
@@ -50,18 +50,13 @@ public sealed partial class MTConnectDriver : IAlarmSource
|
||||
/// <summary>
|
||||
/// ConditionId (RawPath) → what the server was last told. Guarded by <see cref="_alarmLock"/>. Survives a
|
||||
/// re-subscribe with the SAME reference set (a reconnect) and lifecycle restarts — the server's condition
|
||||
/// nodes outlive both, and re-raising there would un-acknowledge. Cleared WHOLE when the subscribed set
|
||||
/// changes: only an alarm-bearing raw-tag delta changes it, that delta is a full address-space rebuild,
|
||||
/// and a rebuild recreates every condition node inactive — so everything must be told again.
|
||||
/// nodes outlive both, and re-raising there would un-acknowledge. When the subscribed set changes (an
|
||||
/// alarm-bearing raw-tag delta, i.e. a full rebuild) only the entries that LEFT the set are dropped: the
|
||||
/// server carries every surviving condition's state across the rebuild (Gitea #641), so a survivor needs
|
||||
/// no re-tell, while a condition that comes back later was materialised fresh and is told again.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, MTConnectToldCondition> _deliveredConditions = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// The reference set of the last <see cref="SubscribeAlarmsAsync"/>, kept across an unsubscribe so a
|
||||
/// release-then-resubscribe of an identical set is still recognised. Guarded by <see cref="_alarmLock"/>.
|
||||
/// </summary>
|
||||
private HashSet<string>? _lastAlarmReferences;
|
||||
|
||||
/// <summary>The CONDITION tracker for the current session; replaced alongside the observation index.</summary>
|
||||
private MTConnectConditionTracker _conditions = new();
|
||||
|
||||
@@ -108,16 +103,15 @@ public sealed partial class MTConnectDriver : IAlarmSource
|
||||
|
||||
lock (_alarmLock)
|
||||
{
|
||||
// A CHANGED set means an alarm-bearing delta, which the server applies as a full rebuild: every
|
||||
// condition node — including ones still in the set — was recreated inactive and has been told
|
||||
// nothing. Re-tell all of them. The SAME set is a reconnect: keep the record, or every still-active
|
||||
// alarm would be re-raised and lose its acknowledgement.
|
||||
if (_lastAlarmReferences is null || !_lastAlarmReferences.SetEquals(references))
|
||||
// Forget only the conditions that LEFT the set (Gitea #641). A changed set is an alarm-bearing delta,
|
||||
// which the server applies as a full rebuild — but the server carries every surviving condition's
|
||||
// state (acknowledgement included) across that rebuild, so re-raising a survivor would only
|
||||
// un-acknowledge it. A condition that left and later comes back was materialised fresh and has no
|
||||
// record, so it is told again. The SAME set (a reconnect) prunes nothing.
|
||||
foreach (var conditionId in _deliveredConditions.Keys.Where(k => !references.Contains(k)).ToList())
|
||||
{
|
||||
_deliveredConditions.Clear();
|
||||
_deliveredConditions.Remove(conditionId);
|
||||
}
|
||||
|
||||
_lastAlarmReferences = references;
|
||||
}
|
||||
|
||||
EmitConditionTransitions(touchedDataItemIds: null, resync: true);
|
||||
|
||||
@@ -65,6 +65,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// a native re-materialise adds, and <see cref="RebuildAddressSpace"/> clears it alongside
|
||||
/// <see cref="_alarmConditions"/> so a re-materialise as the other kind is correct.</summary>
|
||||
private readonly HashSet<RealmKey> _nativeAlarmNodeIds = new();
|
||||
/// <summary>Gitea #641: every NATIVE condition's live state, captured by <see cref="RebuildAddressSpace"/>
|
||||
/// just before it clears <see cref="_alarmConditions"/>, waiting for <see cref="MaterialiseAlarmCondition"/>
|
||||
/// to re-create the same id as native in the same apply. Whatever is unclaimed when
|
||||
/// <see cref="CompleteAddressSpaceApply"/> runs is discarded. Guarded by <c>Lock</c>.</summary>
|
||||
private readonly Dictionary<RealmKey, NativeConditionCarryOver> _rebuildCarryOver = new();
|
||||
/// <summary>Gitea #641: conditions whose non-normal state a re-materialise restored, owed ONE condition
|
||||
/// event at <see cref="CompleteAddressSpaceApply"/> (deferred until then so every notifier folder the
|
||||
/// apply wires receives it). Each entry pins the instance restored, so a condition replaced in between is
|
||||
/// not reported. Guarded by <c>Lock</c>.</summary>
|
||||
private readonly List<(RealmKey Key, AlarmConditionState Alarm)> _restoredConditionReports = new();
|
||||
/// <summary>Phase C: NodeId → resolved historian tagname for every variable materialised
|
||||
/// Historizing. Populated by <see cref="EnsureVariable"/> when a historian tagname is supplied; the
|
||||
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
|
||||
@@ -1432,9 +1442,109 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// H6a: record native (driver-fed) conditions so a later task can route their inbound
|
||||
// Acknowledge to the driver rather than the scripted engine.
|
||||
if (isNative) _nativeAlarmNodeIds.Add(conditionKey);
|
||||
|
||||
// Gitea #641: a native condition the rebuild in this apply discarded gets its live state back. Removed
|
||||
// whatever the kind, so a native→scripted swap on the same id drops the capture instead of leaving it
|
||||
// for a later re-add to resurrect.
|
||||
if (_rebuildCarryOver.Remove(conditionKey, out var carried) && isNative)
|
||||
RestoreCarryOver(alarm, conditionKey, carried);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — one native condition's live Part 9 state, carried across a full
|
||||
/// <see cref="RebuildAddressSpace"/>.
|
||||
/// </summary>
|
||||
/// <param name="State">The gate-relevant fields, read off the node as <see cref="ReadConditionDelta"/> does.</param>
|
||||
/// <param name="Time">The condition's <c>Time</c> — the instant its current state came about.</param>
|
||||
/// <param name="ActiveTransitionTime">When it last changed Active, if the SDK recorded it.</param>
|
||||
/// <param name="QualityTimestamp">The source timestamp of its Quality annotation, if present.</param>
|
||||
/// <param name="Comment">The condition's <c>Comment</c> (a client's ack / AddComment text), if any.</param>
|
||||
/// <param name="ClientUserId">Who left that comment, if recorded.</param>
|
||||
private sealed record NativeConditionCarryOver(
|
||||
AlarmConditionDelta State, DateTime Time, DateTime? ActiveTransitionTime, DateTime? QualityTimestamp,
|
||||
LocalizedText? Comment = null, string? ClientUserId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — read what a native condition node holds right now, so the rebuild can put it back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why the NODE, not a host-side cache.</b> The node is the only place that knows the true state:
|
||||
/// a client's Acknowledge (and its comment) is applied to the node by the SDK and never passes through
|
||||
/// the driver or <c>NativeAlarmProjector</c>. Replaying from any host-side record would un-acknowledge it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why it is needed at all.</b> A rebuild recreates every condition inactive + acknowledged. When the
|
||||
/// alarm-tag set is unchanged — the common case: adding a <c>UnsTagReference</c> to an alarm-bearing raw
|
||||
/// tag is enough to force one — the host does not re-subscribe the driver's alarms, and even a
|
||||
/// re-subscribe would not help a transition-only source (Galaxy, OpcUaClient) with nothing to replay.
|
||||
/// Without this, every active alarm read normal until its next device transition.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="condition">The live native condition, about to be discarded.</param>
|
||||
/// <returns>Its carried state.</returns>
|
||||
private static NativeConditionCarryOver CaptureCarryOver(AlarmConditionState condition) => new(
|
||||
ReadConditionDelta(condition),
|
||||
condition.Time?.Value ?? DateTime.UtcNow,
|
||||
condition.ActiveState?.TransitionTime?.Value,
|
||||
condition.Quality?.SourceTimestamp?.Value,
|
||||
condition.Comment?.Value,
|
||||
condition.ClientUserId?.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — put a carried state back onto the freshly materialised native condition. MUST be called
|
||||
/// under <c>Lock</c>, after the condition is registered.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Quality always; the rest only off-normal.</b> Source quality is annotated only on a driver's
|
||||
/// connectivity edge, so a rebuild otherwise left every native condition — normal ones included —
|
||||
/// reading <c>BadWaitingForInitialData</c> until the driver next flapped; it is restored silently. A
|
||||
/// condition in the no-event position (inactive + acknowledged) otherwise keeps exactly what the
|
||||
/// materialise built, message and authored severity included, and reports nothing — the same rule the
|
||||
/// scripted re-assert follows (#487).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Reported later, once.</b> The state is applied now, so a Read or ConditionRefresh in the window
|
||||
/// already sees it, but the one condition event waits for <see cref="CompleteAddressSpaceApply"/>: the
|
||||
/// equipment folders the condition reports through are wired AFTER this call
|
||||
/// (<see cref="WireAlarmNotifiers"/>), and reporting now would miss them. Native conditions refuse
|
||||
/// Confirm, shelving and Enable/Disable (#587), so Enabled and Shelving are not carried.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="alarm">The new condition instance.</param>
|
||||
/// <param name="conditionKey">Its key.</param>
|
||||
/// <param name="carried">What the discarded instance held.</param>
|
||||
private void RestoreCarryOver(AlarmConditionState alarm, RealmKey conditionKey, NativeConditionCarryOver carried)
|
||||
{
|
||||
var state = carried.State;
|
||||
if (alarm.Quality is not null)
|
||||
{
|
||||
alarm.Quality.Value = state.Quality;
|
||||
if (alarm.Quality.SourceTimestamp is not null && carried.QualityTimestamp is { } qualityAt)
|
||||
alarm.Quality.SourceTimestamp.Value = qualityAt;
|
||||
}
|
||||
|
||||
var retained = state.Active || !state.Acknowledged;
|
||||
if (!retained) return;
|
||||
|
||||
alarm.SetActiveState(SystemContext, state.Active);
|
||||
if (carried.ActiveTransitionTime is { } activeAt && alarm.ActiveState?.TransitionTime is { } transitionTime)
|
||||
transitionTime.Value = activeAt;
|
||||
alarm.SetAcknowledgedState(SystemContext, state.Acknowledged);
|
||||
if (alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, state.Confirmed);
|
||||
alarm.SetSeverity(SystemContext, (EventSeverity)state.MappedSeverity);
|
||||
alarm.Message.Value = new LocalizedText(state.Message);
|
||||
alarm.Retain.Value = true;
|
||||
if (alarm.Comment is not null && carried.Comment is not null) alarm.Comment.Value = carried.Comment;
|
||||
if (alarm.ClientUserId is not null && carried.ClientUserId is not null) alarm.ClientUserId.Value = carried.ClientUserId;
|
||||
alarm.Time.Value = carried.Time;
|
||||
alarm.ReceiveTime.Value = carried.Time;
|
||||
|
||||
_restoredConditionReports.Add((conditionKey, alarm));
|
||||
}
|
||||
|
||||
/// <summary>One materialised native-alarm condition together with the equipment folders wired as extra
|
||||
/// event-notifier roots for it. The <see cref="Folders"/> list is mutated in place under <c>Lock</c> as
|
||||
/// notifiers are wired / torn down; <see cref="Alarm"/> is the concrete <see cref="AlarmConditionState"/>
|
||||
@@ -3207,6 +3317,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
_historizedTagnames.Clear();
|
||||
_arrayOnScalarRefused.Clear();
|
||||
|
||||
// Gitea #641: capture every NATIVE condition's live state before the conditions are discarded, so the
|
||||
// re-materialise that follows in this same apply can put it back (see CaptureCarryOver). Scripted
|
||||
// conditions are not captured — their engine re-asserts them after the reload (#487).
|
||||
_rebuildCarryOver.Clear();
|
||||
_restoredConditionReports.Clear();
|
||||
foreach (var (key, alarm) in _alarmConditions)
|
||||
{
|
||||
if (_nativeAlarmNodeIds.Contains(key)) _rebuildCarryOver[key] = CaptureCarryOver(alarm);
|
||||
}
|
||||
|
||||
foreach (var alarm in _alarmConditions.Values)
|
||||
{
|
||||
alarm.Parent?.RemoveChild(alarm);
|
||||
@@ -3251,6 +3371,41 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IOpcUaAddressSpaceSink.CompleteAddressSpaceApply"/>
|
||||
/// <remarks>
|
||||
/// Gitea #641: reports ONE condition event for each native condition whose off-normal state the rebuild
|
||||
/// carried over (stamped with the instant that state came about, not the deploy time — as the scripted
|
||||
/// re-assert does), then discards whatever captured state no materialise claimed. A condition the apply
|
||||
/// replaced again after restoring it is skipped: the instance reported must be the one restored.
|
||||
/// </remarks>
|
||||
public void CompleteAddressSpaceApply()
|
||||
{
|
||||
int reported = 0, discarded;
|
||||
lock (Lock)
|
||||
{
|
||||
foreach (var (key, alarm) in _restoredConditionReports)
|
||||
{
|
||||
if (!_alarmConditions.TryGetValue(key, out var live) || !ReferenceEquals(live, alarm)) continue;
|
||||
// The node's CURRENT Time: the carried instant when nothing reported since the restore, or the
|
||||
// newer instant of a report that did (a client method call on an SDK thread can land between the
|
||||
// restore and here) — never roll the condition's Time back.
|
||||
ReportConditionEvent(alarm, alarm.Time.Value);
|
||||
reported++;
|
||||
}
|
||||
|
||||
discarded = _rebuildCarryOver.Count;
|
||||
_restoredConditionReports.Clear();
|
||||
_rebuildCarryOver.Clear();
|
||||
}
|
||||
|
||||
if (reported > 0 || discarded > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"OtOpcUaNodeManager: rebuild carried {Reported} active/unacknowledged native condition(s) over; discarded {Discarded} captured condition(s) the apply did not re-create",
|
||||
reported, discarded);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>
|
||||
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
|
||||
{
|
||||
|
||||
@@ -84,6 +84,9 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
|
||||
/// <inheritdoc />
|
||||
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CompleteAddressSpaceApply() => _nodeManager.CompleteAddressSpaceApply();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId, realm);
|
||||
|
||||
|
||||
@@ -1793,9 +1793,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
// #626 — a QUALITY-ONLY annotation (an MTConnect condition gone Unavailable, or back) is not an alarm
|
||||
// transition. It rides the SAME quality-only node path the #477 connectivity annotation uses
|
||||
// (AlarmQualityUpdate → WriteAlarmQuality, which touches Quality and nothing else), never a projected
|
||||
// snapshot: the projector's prior state is cleared on every apply, so a projected "keep the state"
|
||||
// after a deploy would default to inactive+acked and silently acknowledge a retained, unacked
|
||||
// condition — and would rewrite its message and severity. Ungated by role (a Secondary keeps quality
|
||||
// snapshot: the projector never sees a client's acknowledgement (that is applied to the node by the
|
||||
// SDK) and forgets a condition a deploy removed, so a projected "keep the state" can disagree with
|
||||
// the node — silently acknowledging a retained, unacked condition or rewriting its message and
|
||||
// severity. Ungated by role (a Secondary keeps quality
|
||||
// warm, as for connectivity) and no alerts row.
|
||||
if (msg.Args is { IsQualityOnly: true, QualityStatusCode: { } quality })
|
||||
{
|
||||
@@ -2017,6 +2018,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
return;
|
||||
}
|
||||
|
||||
// Gitea #641 review — the node is already acknowledged (the SDK applies the client's ack); tell the
|
||||
// projector too, or the next driver Clear projects (Active=false, Acked=prev=false) and un-acknowledges it.
|
||||
// Most sources (MTConnect, FOCAS) send no Acknowledge transition back, and the projector now survives a
|
||||
// deploy rather than resetting to its acked default.
|
||||
_nativeAlarmProjector.MarkAcknowledged(msg.ConditionNodeId);
|
||||
|
||||
if (!_children.TryGetValue(target.DriverInstanceId, out var entry))
|
||||
{
|
||||
_log.Debug("DriverHost {Node}: driver {Driver} not running for alarm ack of {Cond} — dropped",
|
||||
@@ -3078,7 +3085,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
_alarmNodeIdByDriverRef.Clear();
|
||||
_driverRefByAlarmNodeId.Clear();
|
||||
_alarmMetaByNodeId.Clear();
|
||||
_nativeAlarmProjector.Clear();
|
||||
// NOT _nativeAlarmProjector.Clear() (Gitea #641): it is pruned to the still-deployed conditions after the
|
||||
// maps are rebuilt below, so a redeploy that keeps an alarm keeps its projected prior state.
|
||||
// The per-driver secondary indexes are rebuilt WHOLE alongside their primaries below — same pass,
|
||||
// same clear — so no incremental maintenance exists to get out of step with them.
|
||||
_valueRefsByDriver.Clear();
|
||||
@@ -3132,6 +3140,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea #641 — forget the projected prior state of conditions this composition no longer carries, and keep
|
||||
// the rest. Clearing it whole on every apply made the next driver Acknowledge of a still-active alarm
|
||||
// project Active=false (the default prior), and the next Clear of an unacknowledged one project
|
||||
// Acked=true — on any deploy, rebuild or not. The node manager carries the NODE's state across a rebuild,
|
||||
// so the two stay in step. Keyed by the bare condition NodeId, exactly as ForwardNativeAlarm projects.
|
||||
_nativeAlarmProjector.RetainOnly(_driverRefByAlarmNodeId.Keys.ToHashSet(StringComparer.Ordinal));
|
||||
|
||||
// Cache the per-driver payload as THE authority, in the same clear-and-repopulate pass as the routing
|
||||
// maps above so a rename/removal can never leave a stale entry behind. Keyed over the UNION of the two
|
||||
// ref maps AND the running children: a child with no authored tags must get an explicit EMPTY set (which
|
||||
|
||||
@@ -40,9 +40,36 @@ public sealed class NativeAlarmProjector
|
||||
StatusCode: e.QualityStatusCode ?? OpcUaStatus.Good);
|
||||
}
|
||||
|
||||
/// <summary>Clears all tracked per-node state (call on address-space rebuild).</summary>
|
||||
/// <summary>
|
||||
/// Gitea #641 review — record a client acknowledgement the node manager applied to <paramref name="nodeId"/>
|
||||
/// and routed toward the driver. The projector otherwise never sees it, and a later Clear would project the
|
||||
/// stale unacknowledged prior. An untracked condition already defaults to acknowledged.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The acknowledged condition node id.</param>
|
||||
public void MarkAcknowledged(string nodeId)
|
||||
{
|
||||
if (_prior.TryGetValue(nodeId, out var p)) _prior[nodeId] = (p.Active, true);
|
||||
}
|
||||
|
||||
/// <summary>Clears all tracked per-node state.</summary>
|
||||
public void Clear() => _prior.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — drop the tracked state of every condition NOT in <paramref name="deployedNodeIds"/>, keeping
|
||||
/// the rest. Called on every apply instead of <see cref="Clear"/>: a condition that is still deployed keeps
|
||||
/// its state across the deploy (the node manager carries the node's state across a rebuild too), so the
|
||||
/// next Acknowledge does not project <c>Active = false</c> onto an active alarm and the next Clear does not
|
||||
/// project <c>Acked = true</c> onto an unacknowledged one. A removed condition is forgotten, so a later
|
||||
/// re-add starts from the default.
|
||||
/// </summary>
|
||||
/// <param name="deployedNodeIds">The condition node ids the new composition still materialises.</param>
|
||||
public void RetainOnly(IReadOnlySet<string> deployedNodeIds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deployedNodeIds);
|
||||
foreach (var nodeId in _prior.Keys.Where(k => !deployedNodeIds.Contains(k)).ToList())
|
||||
_prior.Remove(nodeId);
|
||||
}
|
||||
|
||||
private static ushort MapSeverity(AlarmSeverity s) => s switch
|
||||
{
|
||||
AlarmSeverity.Low => 200,
|
||||
|
||||
@@ -437,6 +437,27 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
private void HandleRebuild(RebuildAddressSpace msg)
|
||||
{
|
||||
try
|
||||
{
|
||||
HandleRebuildCore(msg);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Gitea #641 — close the apply on EVERY exit, early returns and a throwing pass included: the node
|
||||
// manager reports here the native conditions a rebuild carried over (after the notifier wiring the
|
||||
// passes did) and discards any captured state no materialise claimed. Skipping it on a throw would
|
||||
// leave that state for a later surgical re-add of the same id to resurrect. No-op when nothing is
|
||||
// pending; never throws out of the handler.
|
||||
try { _sink.CompleteAddressSpaceApply(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRebuildCore(RebuildAddressSpace msg)
|
||||
{
|
||||
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
||||
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
|
||||
|
||||
+5
-3
@@ -80,11 +80,13 @@ public class DeferredSinkForwardingReflectionTests
|
||||
// v3 B4-WP2 dual-namespace invariant: a node id alone is no longer globally unique across the two
|
||||
// namespaces (Raw vs UNS), so every sink method that NAMES a node must carry an AddressSpaceRealm
|
||||
// discriminator — the sink resolves the namespace from the realm rather than parsing the id string.
|
||||
// RebuildAddressSpace is the sole node-naming-free method (it clears BOTH realms). This guard locks in
|
||||
// the realm surface so a future method can't be added that names a node without a realm.
|
||||
// RebuildAddressSpace (it clears BOTH realms) and CompleteAddressSpaceApply (it closes an apply pass,
|
||||
// Gitea #641) are the only node-naming-free methods. This guard locks in the realm surface so a future
|
||||
// method can't be added that names a node without a realm.
|
||||
foreach (var method in ForwardingInterfaces.SelectMany(i => i.GetMethods()))
|
||||
{
|
||||
if (method.Name == nameof(IOpcUaAddressSpaceSink.RebuildAddressSpace)) continue;
|
||||
if (method.Name is nameof(IOpcUaAddressSpaceSink.RebuildAddressSpace)
|
||||
or nameof(IOpcUaAddressSpaceSink.CompleteAddressSpaceApply)) continue;
|
||||
|
||||
method.GetParameters().Any(p => CarriesRealm(p.ParameterType)).ShouldBeTrue(
|
||||
$"{method.DeclaringType!.Name}.{method.Name} names a node but has no AddressSpaceRealm parameter — " +
|
||||
|
||||
@@ -189,6 +189,80 @@ public sealed class AbCipAlarmProjectionTests
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
// Gitea #641 (item 4): the host routes a native transition on ConditionId == the subscribed RawPath and
|
||||
// projects it from Kind. The projection used to emit "{ref}#active" with an Unspecified kind, so every
|
||||
// ALMD transition was dropped as "no alarm condition" — and would have projected no state change even if
|
||||
// it had routed.
|
||||
AlarmEventArgs raised, cleared;
|
||||
lock (events)
|
||||
{
|
||||
raised = events.First(e => e.Message.Contains("raised"));
|
||||
cleared = events.First(e => e.Message.Contains("cleared"));
|
||||
}
|
||||
raised.ConditionId.ShouldBe("HighTemp");
|
||||
raised.Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
cleared.ConditionId.ShouldBe("HighTemp");
|
||||
cleared.Kind.ShouldBe(AlarmTransitionKind.Clear);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 review — the server carries every surviving native condition's state (a client's
|
||||
/// acknowledgement included) across an address-space rebuild, and the host re-subscribes alarms on every
|
||||
/// reconnect and every alarm-set change. A re-subscribe used to start from an empty "last seen" record, so
|
||||
/// a still-faulted ALMD was re-RAISED, which the host projects as Active+UNacked — un-acknowledging an alarm
|
||||
/// an operator had already acknowledged. The record is the driver's, kept for refs that stay in the set.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_resubscribe_does_not_re_raise_a_still_faulted_alarm()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var raises = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { if (e.Kind == AlarmTransitionKind.Raise) lock (raises) raises.Add(e); };
|
||||
|
||||
var sub1 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
|
||||
await WaitForEventAsync(raises, _ => true);
|
||||
|
||||
// A reconnect / alarm-set change: the same ref, still faulted.
|
||||
var sub2 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForMoreReadsAsync(factory, "HighTemp", 6);
|
||||
|
||||
lock (raises) raises.Count.ShouldBe(1, "a re-subscribe must not re-raise (and so un-acknowledge) a still-faulted alarm");
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None);
|
||||
await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Gitea #641 review — positive control: a ref that LEFT the subscribed set was materialised fresh
|
||||
/// (inactive) when it came back, so it is raised again.</summary>
|
||||
[Fact]
|
||||
public async Task A_ref_that_left_the_set_and_came_back_is_raised_again()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var raises = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { if (e.Kind == AlarmTransitionKind.Raise) lock (raises) raises.Add(e); };
|
||||
|
||||
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
|
||||
await WaitForEventAsync(raises, _ => true);
|
||||
|
||||
await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForEventAsync(raises, _ => raises.Count >= 2);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unsubscribing stops the alarm poll loop.</summary>
|
||||
|
||||
+13
-9
@@ -151,27 +151,31 @@ public sealed class MTConnectAlarmSourceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_changed_alarm_set_re_tells_every_condition_and_the_same_set_does_not()
|
||||
public async Task A_changed_alarm_set_re_tells_only_conditions_that_left_and_came_back()
|
||||
{
|
||||
// Review MAJOR 2: a changed set is an alarm-bearing delta ⇒ full rebuild ⇒ every condition node was
|
||||
// recreated inactive, so an active condition still in the set must be re-asserted onto it.
|
||||
// Gitea #641: a changed set is an alarm-bearing delta ⇒ a full rebuild, but the server now carries every
|
||||
// surviving condition's state (acknowledgement included) across it. Re-raising a survivor would
|
||||
// UN-acknowledge it, so only the record of a condition that LEFT the set is dropped: one that comes back
|
||||
// was materialised fresh and must be told again.
|
||||
var (driver, agent, events) = await AlarmDriverAsync();
|
||||
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
|
||||
events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
|
||||
await driver.SubscribeAlarmsAsync([ConditionRawPath, OtherConditionRawPath], Ct);
|
||||
events.Count.ShouldBe(2, "A is re-raised onto its rebuilt node");
|
||||
await driver.SubscribeAlarmsAsync([ConditionRawPath, OtherConditionRawPath], Ct); // A survives: silent
|
||||
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct); // same set: silent
|
||||
events.Count.ShouldBe(1, "a surviving condition is not re-raised");
|
||||
|
||||
await driver.SubscribeAlarmsAsync([OtherConditionRawPath], Ct); // A removed
|
||||
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct); // A re-added: fresh node
|
||||
events.Count.ShouldBe(2, "a condition that left the set and came back is told again");
|
||||
events.Last().ConditionId.ShouldBe(ConditionRawPath);
|
||||
events.Last().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
|
||||
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct);
|
||||
|
||||
// Positive control: the next real transition is the very next event.
|
||||
await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
|
||||
events.Select(e => e.Kind).ShouldBe(
|
||||
[AlarmTransitionKind.Raise, AlarmTransitionKind.Raise, AlarmTransitionKind.Clear],
|
||||
"the same set (a reconnect) must re-raise nothing");
|
||||
[AlarmTransitionKind.Raise, AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
|
||||
|
||||
await driver.ShutdownAsync(Ct);
|
||||
}
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 over the wire — a full address-space rebuild that leaves the native alarm set unchanged must not
|
||||
/// leave an active condition reading inactive. A Server-object (<c>i=2253</c>) item, which outlives the rebuild
|
||||
/// (#586), receives exactly ONE condition event carrying the restored state — and an acknowledgement a client
|
||||
/// applied before the rebuild survives it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The rebuild is driven exactly as <c>OpcUaPublishActor</c> drives one: <c>RebuildAddressSpace</c>, the
|
||||
/// re-materialise + notifier wiring, then <c>CompleteAddressSpaceApply</c>. The count is read after a later
|
||||
/// sentinel transition arrives on the same item, so a duplicate would already be queued ahead of it.
|
||||
/// The native ack router echoes nothing back, like MTConnect and FOCAS: the acknowledgement lives only on the node.
|
||||
/// </remarks>
|
||||
public sealed class NativeAlarmRebuildCarryOverWireTests
|
||||
{
|
||||
private const NodePermissions All =
|
||||
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead
|
||||
| NodePermissions.AlarmAcknowledge | NodePermissions.MethodCall;
|
||||
|
||||
private static AlarmConditionSnapshot Fault(string message) => new(
|
||||
Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 800, Message: message);
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task A_rebuild_with_an_unchanged_alarm_set_reports_the_restored_state_once(bool clientAcked)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync($"Rebuild641{clientAcked}", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.Nm.NativeAlarmAckRouter = _ => { }; // a driver whose ack is a no-op: nothing echoes back
|
||||
rig.MaterialiseNative();
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Project(native: true, Fault("fault"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "fault"));
|
||||
var expectedBefore = 1;
|
||||
|
||||
if (clientAcked)
|
||||
{
|
||||
await session.CallAsync(
|
||||
ConditionNodeId(session, native: true), MethodIds.AcknowledgeableConditionType_Acknowledge, ct,
|
||||
events.LatestEventId(serverItem, "fault"), new LocalizedText("ack"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(serverItem, "fault").Count == 2);
|
||||
expectedBefore = 2;
|
||||
}
|
||||
|
||||
// The deploy: a rebuild whose composition carries the same alarm (e.g. one UnsTagReference added).
|
||||
rig.Sink.RebuildAddressSpace();
|
||||
rig.MaterialiseNative();
|
||||
rig.Sink.CompleteAddressSpaceApply();
|
||||
|
||||
rig.Project(native: true, Fault("sentinel") with { Acknowledged = clientAcked });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "sentinel"));
|
||||
|
||||
var faultEvents = events.ConditionEvents(serverItem, "fault");
|
||||
faultEvents.Count.ShouldBe(expectedBefore + 1, "the pre-rebuild event(s) plus exactly one restored event");
|
||||
var restored = faultEvents[^1];
|
||||
restored[RecordedEvents.ActiveField].Value.ShouldBe(true);
|
||||
restored[RecordedEvents.AckedField].Value.ShouldBe(clientAcked);
|
||||
restored[RecordedEvents.RetainField].Value.ShouldBe(true);
|
||||
restored[RecordedEvents.SeverityField].Value.ShouldBe((ushort)800);
|
||||
|
||||
// And a ConditionRefresh-style read of the live node agrees.
|
||||
var active = await session.ReadValueAsync(
|
||||
new NodeId($"{RawAlarmPath}/ActiveState/Id", ConditionNodeId(session, native: true).NamespaceIndex), ct);
|
||||
active.Value.ShouldBe(true);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>Falsification control: a NORMAL native condition produces no event across the rebuild.</summary>
|
||||
[Fact]
|
||||
public async Task A_rebuild_reports_nothing_for_a_normal_condition()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("Rebuild641Normal", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseNative();
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Project(native: true, Fault("cleared") with { Active = false, Acknowledged = true });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "cleared"));
|
||||
|
||||
rig.Sink.RebuildAddressSpace();
|
||||
rig.MaterialiseNative();
|
||||
rig.Sink.CompleteAddressSpaceApply();
|
||||
|
||||
rig.Project(native: true, Fault("sentinel"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "sentinel"));
|
||||
|
||||
events.ConditionEvents(serverItem, "cleared").Count.ShouldBe(1);
|
||||
events.ConditionEvents(serverItem, "temp_hi").ShouldBeEmpty("the fresh node is not reported");
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — a full <see cref="OtOpcUaNodeManager.RebuildAddressSpace"/> recreates every native condition
|
||||
/// inactive, and when the alarm-tag set is unchanged nothing tells the new node its state again (the driver
|
||||
/// does not re-subscribe, and Galaxy / OpcUaClient have nothing to replay anyway). The node manager therefore
|
||||
/// carries each NATIVE condition's live state across the rebuild: captured before the clear, restored when
|
||||
/// the same id is re-materialised native in the same apply, and reported as ONE condition event when the
|
||||
/// apply completes (<see cref="OtOpcUaNodeManager.CompleteAddressSpaceApply"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// "One event" is observed here through the condition's <c>EventId</c>, which every report stamps fresh.
|
||||
/// The exact count over the wire is pinned by <c>NativeAlarmRebuildCarryOverWireTests</c>.
|
||||
/// </remarks>
|
||||
public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable
|
||||
{
|
||||
private const string Device = "Plant/MTConnect/dev1";
|
||||
private const string Alarm = "Plant/MTConnect/dev1/Xtravel";
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-641-{Guid.NewGuid():N}");
|
||||
|
||||
private static AlarmConditionSnapshot Fault(bool acked) => new(
|
||||
Active: true, Acknowledged: acked, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 800, Message: "Xtravel FAULT 1001",
|
||||
StatusCode: OpcUaStatus.Good);
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task An_active_unacked_native_condition_survives_a_rebuild_and_is_reported_once()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
|
||||
Materialise(nm, native: true);
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false), raisedAt, AddressSpaceRealm.Raw);
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: true);
|
||||
var restored = Condition(nm);
|
||||
var idBeforeComplete = (byte[]?)restored.EventId.Value?.Clone();
|
||||
|
||||
// State is back as soon as the node exists — a Read or a ConditionRefresh in between already sees it.
|
||||
restored.ActiveState.Id.Value.ShouldBeTrue();
|
||||
restored.AckedState.Id.Value.ShouldBeFalse();
|
||||
restored.Retain.Value.ShouldBeTrue();
|
||||
restored.Severity.Value.ShouldBe((ushort)800);
|
||||
restored.Message.Value.Text.ShouldBe("Xtravel FAULT 1001");
|
||||
restored.Quality.Value.Code.ShouldBe(StatusCodes.Good);
|
||||
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
// The report happens at the END of the apply (after the notifier wiring), stamped with the instant the
|
||||
// state came about, not the deploy time.
|
||||
restored.EventId.Value.ShouldNotBe(idBeforeComplete);
|
||||
restored.Time.Value.ShouldBe(raisedAt);
|
||||
|
||||
// ...and only once: a second completion with nothing pending reports nothing.
|
||||
var idAfterComplete = (byte[]?)restored.EventId.Value?.Clone();
|
||||
nm.CompleteAddressSpaceApply();
|
||||
restored.EventId.Value.ShouldBe(idAfterComplete);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The acknowledgement a CLIENT applied lives only on the node (the SDK applied it; no driver or projector
|
||||
/// saw it) — the reason the carry-over reads the node rather than any host-side cache.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task A_client_acknowledgement_survives_the_rebuild()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
Materialise(nm, native: true);
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
// What the SDK's Acknowledge handler does to the node; nothing else hears of it.
|
||||
Condition(nm).SetAcknowledgedState(nm.SystemContext, true);
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: true);
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
var restored = Condition(nm);
|
||||
restored.ActiveState.Id.Value.ShouldBeTrue();
|
||||
restored.AckedState.Id.Value.ShouldBeTrue();
|
||||
restored.Retain.Value.ShouldBeTrue();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 review — the comment a client left with its acknowledgement is condition state too (native
|
||||
/// conditions support AddComment), and an event the condition reported AFTER the restore (a client method
|
||||
/// call landing on an SDK thread before the apply completes) must not have its newer <c>Time</c> rolled
|
||||
/// back by the deferred restore report.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task The_comment_is_carried_and_a_newer_report_is_not_rolled_back()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
|
||||
Materialise(nm, native: true);
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false), raisedAt, AddressSpaceRealm.Raw);
|
||||
Condition(nm).Comment.Value = new LocalizedText("on it");
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: true);
|
||||
var later = raisedAt.AddMinutes(5);
|
||||
nm.WriteAlarmQuality(Alarm, StatusCodes.BadNoCommunication, later, AddressSpaceRealm.Raw); // reports at `later`
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
var restored = Condition(nm);
|
||||
restored.Comment.Value.Text.ShouldBe("on it");
|
||||
restored.Time.Value.ShouldBe(later, "the restore report must not stamp the condition back to the raise time");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Falsification control: a condition in the no-event position comes back exactly as a fresh
|
||||
/// materialise builds it, and reports nothing.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task A_normal_native_condition_reports_nothing_and_keeps_the_fresh_state()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
Materialise(nm, native: true);
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false) with { Active = false, Acknowledged = true, Message = "cleared" },
|
||||
DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: true);
|
||||
var fresh = Condition(nm);
|
||||
var idBefore = (byte[]?)fresh.EventId.Value?.Clone();
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
fresh.EventId.Value.ShouldBe(idBefore);
|
||||
fresh.ActiveState.Id.Value.ShouldBeFalse();
|
||||
fresh.AckedState.Id.Value.ShouldBeTrue();
|
||||
fresh.Retain.Value.ShouldBeFalse();
|
||||
fresh.Message.Value.Text.ShouldBe("Xtravel", "a normal condition keeps the materialise's message");
|
||||
fresh.Severity.Value.ShouldBe((ushort)700, "and its authored severity");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The source-data quality is carried for a NORMAL condition too, silently: quality is annotated only on a
|
||||
/// driver's connectivity edge, so without the carry a rebuild left every normal native condition reading
|
||||
/// <c>BadWaitingForInitialData</c> until the driver next flapped.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task A_normal_condition_keeps_its_quality_without_an_event()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
Materialise(nm, native: true);
|
||||
nm.WriteAlarmQuality(Alarm, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: true);
|
||||
var fresh = Condition(nm);
|
||||
var idBefore = (byte[]?)fresh.EventId.Value?.Clone();
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
fresh.Quality.Value.Code.ShouldBe(StatusCodes.Good);
|
||||
fresh.EventId.Value.ShouldBe(idBefore);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>A condition the rebuild did NOT re-create has its captured state discarded when the apply
|
||||
/// completes, so a later deploy that adds the same RawPath back gets a fresh node, not a resurrection.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task A_condition_removed_by_the_rebuild_is_not_resurrected_by_a_later_add()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
Materialise(nm, native: true);
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
nm.CompleteAddressSpaceApply(); // the alarm was removed: nothing re-materialised it
|
||||
|
||||
Materialise(nm, native: true); // a later, surgical re-add of the same RawPath
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
var readded = Condition(nm);
|
||||
readded.ActiveState.Id.Value.ShouldBeFalse();
|
||||
readded.AckedState.Id.Value.ShouldBeTrue();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>A native condition re-materialised as SCRIPTED under the same id is a different alarm: nothing
|
||||
/// is restored onto it (and the scripted engine's own #487 re-assert owns scripted state).</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task A_kind_swap_on_the_same_id_is_not_restored()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
Materialise(nm, native: true);
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: false);
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
Condition(nm).ActiveState.Id.Value.ShouldBeFalse();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Scripted conditions are not captured — <c>ScriptedAlarmHostActor.ReassertConditionNodes</c>
|
||||
/// (#487) already re-projects them after a reload, and a second source would double-report.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task A_scripted_condition_is_not_carried()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
Materialise(nm, native: false);
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: false);
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
Condition(nm).ActiveState.Id.Value.ShouldBeFalse();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
private static void Materialise(OtOpcUaNodeManager nm, bool native)
|
||||
{
|
||||
nm.EnsureFolder(Device, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
nm.MaterialiseAlarmCondition(Alarm, Device, "Xtravel", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: native);
|
||||
}
|
||||
|
||||
private static Opc.Ua.AlarmConditionState Condition(OtOpcUaNodeManager nm) =>
|
||||
nm.TryGetAlarmCondition(Alarm, AddressSpaceRealm.Raw).ShouldNotBeNull();
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.Gitea641Test",
|
||||
ApplicationUri = $"urn:OtOpcUa.Gitea641Test:{Guid.NewGuid():N}",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
var server = new OtOpcUaSdkServer();
|
||||
await host.StartAsync(server, Ct);
|
||||
return (host, server.NodeManager!);
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>Cleans up the PKI root directory.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
-1
@@ -104,6 +104,27 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Gitea #641 (item 4) — routing is an EXACT match on the RawPath: a driver that decorates its
|
||||
/// ConditionId (AbCip used to send <c>"{RawPath}#active"</c>, FOCAS sends <c>"{host}#{type}:{number}"</c>) has
|
||||
/// every transition dropped, so a native source MUST report <c>ConditionId = RawPath</c>.</summary>
|
||||
[Fact]
|
||||
public void A_decorated_condition_id_is_dropped()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(), AlarmRawPath, $"{AlarmRawPath}#active", "ALMD", "decorated", AlarmSeverity.High, Ts,
|
||||
Kind: AlarmTransitionKind.Raise)));
|
||||
// Positive control: the undecorated id routes, and is the first update.
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(), AlarmRawPath, AlarmRawPath, "ALMD", "plain", AlarmSeverity.High, Ts,
|
||||
Kind: AlarmTransitionKind.Raise)));
|
||||
|
||||
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Message.ShouldBe("plain");
|
||||
}
|
||||
|
||||
/// <summary>Primary (default/unset role) fan-out: a native alarm RAISE on a known ref publishes exactly one
|
||||
/// <see cref="AlarmTransitionEvent"/> to the cluster <c>alerts</c> topic with <c>AlarmId</c> = the raw
|
||||
/// condition NodeId (RawPath), alongside the (ungated) OPC UA condition update.</summary>
|
||||
@@ -409,6 +430,93 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — a redeploy that keeps the alarm must not reset the projector's prior state for it. It used
|
||||
/// to be cleared on EVERY apply, so a driver Acknowledge arriving after any deploy projected
|
||||
/// <c>(prev.Active = false, Acked)</c> and wrote an ACTIVE alarm inactive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_acknowledge_after_a_redeploy_keeps_the_alarm_active()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
var (actor, publish, coordinator) = SpawnHostAndApplyWithCoordinator(db, deploymentId);
|
||||
|
||||
actor.Tell(Transition(AlarmTransitionKind.Raise));
|
||||
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Active.ShouldBeTrue();
|
||||
|
||||
Redeploy(db, actor, publish, coordinator);
|
||||
|
||||
actor.Tell(Transition(AlarmTransitionKind.Acknowledge));
|
||||
var acked = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
|
||||
acked.State.Active.ShouldBeTrue("the alarm is still active at the device");
|
||||
acked.State.Acknowledged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Gitea #641 — likewise a Clear after a redeploy must not silently acknowledge an unacknowledged
|
||||
/// alarm (the cleared prior state defaulted Acked to true).</summary>
|
||||
[Fact]
|
||||
public void A_clear_after_a_redeploy_keeps_the_alarm_unacknowledged()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
var (actor, publish, coordinator) = SpawnHostAndApplyWithCoordinator(db, deploymentId);
|
||||
|
||||
actor.Tell(Transition(AlarmTransitionKind.Raise));
|
||||
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Acknowledged.ShouldBeFalse();
|
||||
|
||||
Redeploy(db, actor, publish, coordinator);
|
||||
|
||||
actor.Tell(Transition(AlarmTransitionKind.Clear));
|
||||
var cleared = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
|
||||
cleared.State.Active.ShouldBeFalse();
|
||||
cleared.State.Acknowledged.ShouldBeFalse("nobody acknowledged it");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 review — a CLIENT acknowledgement is applied to the node by the SDK and only ROUTED here; the
|
||||
/// projector must learn of it. Before #641 the projector was cleared on every deploy, so a Clear arriving
|
||||
/// after one defaulted Acked to true and happened to be right for a client-acked alarm; now that the
|
||||
/// projector survives the deploy, a projector that never heard the ack projects the Clear as UNacked and
|
||||
/// un-acknowledges the alarm the operator acknowledged.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_clear_after_a_client_acknowledge_and_a_redeploy_stays_acknowledged()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
var (actor, publish, coordinator) = SpawnHostAndApplyWithCoordinator(db, deploymentId);
|
||||
|
||||
actor.Tell(Transition(AlarmTransitionKind.Raise));
|
||||
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Acknowledged.ShouldBeFalse();
|
||||
|
||||
// The client's Acknowledge on the node, routed toward the driver (no transition comes back for it).
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "seen", "op"));
|
||||
Redeploy(db, actor, publish, coordinator);
|
||||
|
||||
actor.Tell(Transition(AlarmTransitionKind.Clear));
|
||||
var cleared = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
|
||||
cleared.State.Active.ShouldBeFalse();
|
||||
cleared.State.Acknowledged.ShouldBeTrue("the operator acknowledged it before it cleared");
|
||||
}
|
||||
|
||||
private static DriverInstanceActor.AttributeAlarmPublished Transition(AlarmTransitionKind kind) =>
|
||||
new("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(), "Temp", AlarmRawPath, "OffNormalAlarm", kind.ToString(), AlarmSeverity.High, Ts,
|
||||
Kind: kind));
|
||||
|
||||
/// <summary>A second deployment carrying the SAME alarm (a new revision of an identical artifact).</summary>
|
||||
private void Redeploy(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, IActorRef actor, TestProbe publish, TestProbe coordinator)
|
||||
{
|
||||
var revB = RevisionHash.Parse(new string('b', 64));
|
||||
var second = SeedV3AlarmDeployment(db, revB, Driver: "drv-1", Tag: "temp_hi");
|
||||
actor.Tell(new DispatchDeployment(second, revB, CorrelationId.NewId()));
|
||||
coordinator.FishForMessage<ApplyAck>(a => a.DeploymentId == second, TimeSpan.FromSeconds(5))
|
||||
.Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
publish.FishForMessage<OpcUaPublishActor.RebuildAddressSpace>(_ => true, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.</summary>
|
||||
private void SubscribeToAlerts(TestProbe probe)
|
||||
{
|
||||
@@ -439,6 +547,13 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
/// publishes an alarm. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
|
||||
private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||||
{
|
||||
var (actor, publish, _) = SpawnHostAndApplyWithCoordinator(db, deploymentId);
|
||||
return (actor, publish);
|
||||
}
|
||||
|
||||
private (IActorRef Actor, TestProbe Publish, TestProbe Coordinator) SpawnHostAndApplyWithCoordinator(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||||
{
|
||||
var coordinator = CreateTestProbe();
|
||||
var publish = CreateTestProbe();
|
||||
@@ -460,7 +575,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
// ExpectMsg<AlarmStateUpdate> assertions see only alarm updates.
|
||||
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
|
||||
|
||||
return (actor, publish);
|
||||
return (actor, publish, coordinator);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -24,6 +24,21 @@ public class NativeAlarmProjectorTests
|
||||
snap.Message.ShouldBe("boom");
|
||||
}
|
||||
|
||||
/// <summary>Gitea #641 — <c>RetainOnly</c> keeps a still-deployed condition's prior state and forgets a removed
|
||||
/// one's (which then starts from the default inactive + acked).</summary>
|
||||
[Fact]
|
||||
public void RetainOnly_keeps_deployed_conditions_and_forgets_removed_ones()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("kept", Evt(AlarmTransitionKind.Raise));
|
||||
sut.Project("removed", Evt(AlarmTransitionKind.Raise));
|
||||
|
||||
sut.RetainOnly(new HashSet<string>(StringComparer.Ordinal) { "kept" });
|
||||
|
||||
sut.Project("kept", Evt(AlarmTransitionKind.Acknowledge)).Active.ShouldBeTrue();
|
||||
sut.Project("removed", Evt(AlarmTransitionKind.Acknowledge)).Active.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retrigger_projects_active_unacked()
|
||||
{
|
||||
|
||||
@@ -61,6 +61,41 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — every apply ends with <c>CompleteAddressSpaceApply</c>, AFTER the last materialise / announce
|
||||
/// call: that is where the node manager reports the conditions a rebuild carried over (their notifier
|
||||
/// folders are wired by then) and drops any captured state no materialise claimed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_apply_completes_after_every_materialise_pass()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" });
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
|
||||
AwaitAssert(() => sink.CompletedAfterCallCount.Count.ShouldBe(1), duration: PresenceBudget);
|
||||
sink.CompletedAfterCallCount.Single().ShouldBe(sink.Calls.Count, "nothing may follow the completion");
|
||||
sink.Calls.ShouldContain("NA:line-1");
|
||||
}
|
||||
|
||||
/// <summary>Gitea #641 — the raw-sink fallback rebuild (no applier) completes too, so the state it captured
|
||||
/// is discarded rather than left for a later add to resurrect.</summary>
|
||||
[Fact]
|
||||
public void The_raw_sink_fallback_rebuild_completes_the_apply()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
|
||||
AwaitAssert(() => sink.CompletedAfterCallCount.Count.ShouldBe(1), duration: PresenceBudget);
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Tests that rebuild with no artifact is idempotent no-op.</summary>
|
||||
[Fact]
|
||||
public void Rebuild_with_no_artifact_is_idempotent_no_op()
|
||||
@@ -1053,6 +1088,12 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
}
|
||||
/// <summary>Records a rebuild address space call.</summary>
|
||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||
/// <summary>Gets, per <see cref="CompleteAddressSpaceApply"/> call, how many <see cref="Calls"/> had been
|
||||
/// recorded at that moment — so a test can prove the completion came after every pass (Gitea #641).
|
||||
/// Kept out of <see cref="Calls"/> so existing call-count assertions are unaffected.</summary>
|
||||
public ConcurrentQueue<int> CompletedAfterCallCount { get; } = new();
|
||||
/// <summary>Records the end of an apply pass (Gitea #641).</summary>
|
||||
public void CompleteAddressSpaceApply() => CompletedAfterCallCount.Enqueue(Calls.Count);
|
||||
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
||||
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"NA:{affectedNodeId}");
|
||||
|
||||
Reference in New Issue
Block a user