Add alarm acknowledge plan and incorporate code review fixes
Adds alarm_ack.md documenting the two-way acknowledge flow (OPC UA client writes AckMsg, Galaxy confirms via Acked data change). Includes external code review fixes for subscriptions and node manager, and removes stale plan files now superseded by component documentation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
161
alarm_ack.md
Normal file
161
alarm_ack.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Alarm Acknowledge Plan
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The server creates `AlarmConditionState` nodes for Galaxy alarms and monitors `[AlarmTag].InAlarm` for activation. Currently there is no acknowledge support — the `AckedState` is set to false when an alarm activates but there is no way for an OPC UA client to acknowledge it.
|
||||||
|
|
||||||
|
Galaxy alarm acknowledgment works by writing to `[AlarmTag].AckMsg`. Writing any string (including empty) to `AckMsg` triggers the acknowledge in System Platform. Once acknowledged, the runtime sets `[AlarmTag].Acked` to `true`.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Two parts:
|
||||||
|
|
||||||
|
### 1. OPC UA Acknowledge → Galaxy AckMsg Write
|
||||||
|
|
||||||
|
When an OPC UA client calls the Acknowledge method on an `AlarmConditionState` node:
|
||||||
|
|
||||||
|
1. The `OnAcknowledge` callback fires with `(context, condition, eventId, comment)`
|
||||||
|
2. Look up the alarm's `AckMsg` tag reference from `AlarmInfo`
|
||||||
|
3. Write the comment text (or empty string if no comment) to `[AlarmTag].AckMsg` via `_mxAccessClient.WriteAsync`
|
||||||
|
4. Return `Good` — the actual `AckedState` update happens when Galaxy sets `Acked=true` and we receive the data change
|
||||||
|
|
||||||
|
### 2. Galaxy Acked Data Change → OPC UA AckedState Update
|
||||||
|
|
||||||
|
When `[AlarmTag].Acked` changes in the Galaxy runtime:
|
||||||
|
|
||||||
|
1. The auto-subscription delivers a data change to the dispatch loop
|
||||||
|
2. Detect `Acked` tag transitions (same pattern as InAlarm detection)
|
||||||
|
3. Update `condition.SetAcknowledgedState(SystemContext, true/false)` on the `AlarmConditionState`
|
||||||
|
4. Update `condition.Retain.Value` (retain while active or unacknowledged)
|
||||||
|
5. Report the state change event
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### `AlarmInfo` class — add tag references
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public string AckedTagReference { get; set; } = "";
|
||||||
|
public string AckMsgTagReference { get; set; } = "";
|
||||||
|
```
|
||||||
|
|
||||||
|
### `BuildAddressSpace` alarm tracking — populate new fields and wire OnAcknowledge
|
||||||
|
|
||||||
|
In the alarm creation block, after setting up the `AlarmConditionState`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var baseTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']');
|
||||||
|
// existing:
|
||||||
|
PriorityTagReference = baseTagRef + ".Priority",
|
||||||
|
DescAttrNameTagReference = baseTagRef + ".DescAttrName",
|
||||||
|
// new:
|
||||||
|
AckedTagReference = baseTagRef + ".Acked",
|
||||||
|
AckMsgTagReference = baseTagRef + ".AckMsg",
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire the acknowledge callback on the condition node:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
condition.OnAcknowledgeCalled = OnAlarmAcknowledge;
|
||||||
|
```
|
||||||
|
|
||||||
|
### New: `OnAlarmAcknowledge` callback method
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private ServiceResult OnAlarmAcknowledge(
|
||||||
|
ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment)
|
||||||
|
{
|
||||||
|
// Find the AlarmInfo for this condition
|
||||||
|
var alarmInfo = _alarmInAlarmTags.Values
|
||||||
|
.FirstOrDefault(a => a.ConditionNode == condition);
|
||||||
|
if (alarmInfo == null)
|
||||||
|
return StatusCodes.BadNodeIdUnknown;
|
||||||
|
|
||||||
|
// Write the comment to AckMsg — writing any string (including empty) triggers ack in Galaxy
|
||||||
|
var ackMessage = comment?.Text ?? "";
|
||||||
|
_mxAccessClient.WriteAsync(alarmInfo.AckMsgTagReference, ackMessage)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
return ServiceResult.Good;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't update `AckedState` here — wait for the Galaxy to confirm via the `Acked` data change callback.
|
||||||
|
|
||||||
|
### `SubscribeAlarmTags` — subscribe to Acked tag
|
||||||
|
|
||||||
|
Add `AckedTagReference` to the list of tags subscribed per alarm (currently subscribes InAlarm, Priority, DescAttrName).
|
||||||
|
|
||||||
|
### New: `_alarmAckedTags` dictionary
|
||||||
|
|
||||||
|
Maps `[AlarmTag].Acked` tag reference → `AlarmInfo`, similar to `_alarmInAlarmTags` which maps InAlarm tags:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private readonly Dictionary<string, AlarmInfo> _alarmAckedTags = new(...);
|
||||||
|
```
|
||||||
|
|
||||||
|
Populated alongside `_alarmInAlarmTags` during alarm tracking in `BuildAddressSpace`.
|
||||||
|
|
||||||
|
### `DispatchLoop` — detect Acked transitions
|
||||||
|
|
||||||
|
In the dispatch loop preparation phase (outside Lock), after the InAlarm check:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (_alarmAckedTags.TryGetValue(address, out var ackedAlarmInfo))
|
||||||
|
{
|
||||||
|
var newAcked = IsTrue(vtq.Value);
|
||||||
|
pendingAckedEvents.Add((ackedAlarmInfo, newAcked));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the Lock block, apply acked state changes:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
foreach (var (info, acked) in pendingAckedEvents)
|
||||||
|
{
|
||||||
|
var condition = info.ConditionNode;
|
||||||
|
if (condition == null) continue;
|
||||||
|
condition.SetAcknowledgedState(SystemContext, acked);
|
||||||
|
condition.Retain.Value = (condition.ActiveState?.Id?.Value == true) || !acked;
|
||||||
|
// Report through parent and server
|
||||||
|
if (_tagToVariableNode.TryGetValue(info.SourceTagReference, out var src) && src.Parent != null)
|
||||||
|
src.Parent.ReportEvent(SystemContext, condition);
|
||||||
|
Server.ReportEvent(SystemContext, condition);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `TearDownGobjects` — clean up `_alarmAckedTags`
|
||||||
|
|
||||||
|
Remove entries from `_alarmAckedTags` when tearing down gobjects (same as `_alarmInAlarmTags`).
|
||||||
|
|
||||||
|
### `BuildSubtree` — populate `_alarmAckedTags` for new subtree alarms
|
||||||
|
|
||||||
|
Same as the existing alarm tracking in `BuildSubtree` — add `_alarmAckedTags` population.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
**Unit test** — `AlarmAcknowledgeTests.cs`:
|
||||||
|
- Create alarm attribute, trigger InAlarm=true, verify AckedState is false
|
||||||
|
- Simulate Acked=true data change, verify AckedState updates to true
|
||||||
|
- Verify Retain is false when alarm is inactive and acknowledged
|
||||||
|
|
||||||
|
**Integration test**:
|
||||||
|
- Create fixture with alarm attribute
|
||||||
|
- Push InAlarm=true → alarm fires, AckedState=false
|
||||||
|
- Write to AckMsg tag → verify WriteAsync called on MXAccess
|
||||||
|
- Push Acked=true → verify AckedState updates, Retain becomes false (if inactive)
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/.../OpcUa/LmxNodeManager.cs` | Add `AckedTagReference`/`AckMsgTagReference` to AlarmInfo, add `_alarmAckedTags` dict, wire `OnAcknowledgeCalled`, add `OnAlarmAcknowledge` method, detect Acked transitions in DispatchLoop, subscribe to Acked tags, clean up in TearDown/BuildSubtree |
|
||||||
|
| `docs/AlarmTracking.md` | Update to document acknowledge flow |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Build clean, all tests pass
|
||||||
|
2. Deploy service
|
||||||
|
3. `alarms` CLI → subscribe to TestMachine_001
|
||||||
|
4. Trigger alarm (write TestAlarm001=true) → event shows `Unacknowledged`
|
||||||
|
5. In System Platform, acknowledge the alarm → CLI shows updated event with `Acknowledged`
|
||||||
|
6. Or use OPC UA client to call Acknowledge method on the condition node → Galaxy AckMsg is written
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# Full Review Update: `src`
|
|
||||||
|
|
||||||
Overall verdict: **patch is still incorrect**
|
|
||||||
|
|
||||||
This pass verified the previous findings against the current implementation, then performed another full review of `src/` with emphasis on service-mode reliability, continuous OPC UA publishing, and recovery from MXAccess disconnects.
|
|
||||||
|
|
||||||
## Verified Completed
|
|
||||||
|
|
||||||
### [DONE] OPC UA monitored items now start MXAccess subscriptions
|
|
||||||
Verified in `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs:355-423`.
|
|
||||||
|
|
||||||
The node manager now hooks monitored-item create/delete callbacks and translates them into ref-counted `SubscribeTag` / `UnsubscribeTag` calls. This closes the original gap where OPC UA subscriptions never triggered runtime MX subscriptions.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
- `MultiClientTests.MultipleClients_SubscribeToSameTag_AllReceiveDataChanges` passed
|
|
||||||
|
|
||||||
### [DONE] Write timeout no longer reports success
|
|
||||||
Verified in `src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs:101-107`.
|
|
||||||
|
|
||||||
The timeout path now completes the write with `false` instead of `true`, so OPC UA writes are no longer acknowledged when MXAccess never confirms them.
|
|
||||||
|
|
||||||
### [DONE] Monitor retries from `Error` state as well as `Disconnected`
|
|
||||||
Verified in `src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.Monitor.cs:38-43`.
|
|
||||||
|
|
||||||
The reconnect loop now treats `ConnectionState.Error` as reconnectable, which closes the prior issue where one failed reconnect left the client permanently stuck.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
- `MxAccessClientMonitorTests.Monitor_ReconnectsOnDisconnect` passed
|
|
||||||
- `MxAccessClientMonitorTests.Monitor_ProbeStale_ForcesReconnect` passed
|
|
||||||
|
|
||||||
### [DONE] Address-space construction no longer depends on input ordering
|
|
||||||
Verified in `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs:75-77` and `:174-199`.
|
|
||||||
|
|
||||||
The added topological sort ensures parents are materialized before children even when the hierarchy input is unsorted.
|
|
||||||
|
|
||||||
### [DONE] Startup no longer forces an immediate redundant rebuild
|
|
||||||
Verified in `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs:143-184` and `src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/ChangeDetectionService.cs:25-30`.
|
|
||||||
|
|
||||||
The initial deploy time is now captured during startup and passed into `ChangeDetectionService`, preventing the unconditional first-poll rebuild when startup already loaded the same deploy.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
- `ChangeDetectionServiceTests` passed
|
|
||||||
|
|
||||||
## New Findings
|
|
||||||
|
|
||||||
### [P1] Service never recovers if MXAccess is unavailable during startup
|
|
||||||
Files:
|
|
||||||
- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs:107-123`
|
|
||||||
- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs:139-140`
|
|
||||||
|
|
||||||
If the initial `MxAccessClient.ConnectAsync()` fails, startup disposes the real client and STA thread, replaces them with `NullMxAccessClient`, and still starts the OPC UA server. After that point there is no code path that ever retries the real MXAccess connection, so a temporary LMX outage at service startup becomes a permanent loss of runtime publishing until the whole Windows service is restarted.
|
|
||||||
|
|
||||||
### [P1] Address-space rebuild loses live subscription bookkeeping
|
|
||||||
Files:
|
|
||||||
- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs:163-169`
|
|
||||||
- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs:390-423`
|
|
||||||
|
|
||||||
`RebuildAddressSpace()` clears `_subscriptionRefCounts` without unsubscribing the existing MXAccess subscriptions or rebuilding the ref counts for still-active monitored items. If a Galaxy rebuild happens while clients are subscribed, later deletes no longer call `_mxAccessClient.UnsubscribeAsync(...)`, and any future subscription to the same tag starts from zero and creates another runtime subscription. In a long-running service this leaks live subscriptions and can duplicate data-change delivery after successive rebuilds.
|
|
||||||
|
|
||||||
### [P2] Failed reads and writes are still recorded as successful operations
|
|
||||||
Files:
|
|
||||||
- `src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs:36-42`
|
|
||||||
- `src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs:97-109`
|
|
||||||
- `src/ZB.MOM.WW.LmxOpcUa.Host/Status/HealthCheckService.cs:24-38`
|
|
||||||
|
|
||||||
`PerformanceMetrics` only gets marked unsuccessful when an exception is thrown. A read timeout returns `Vtq.Bad(...)` and a rejected or timed-out write returns `false`, but neither path calls `scope.SetSuccess(false)`. Since the dashboard health logic uses those success rates to detect degraded behavior, sustained runtime failures can still leave the service reporting healthy even while reads or writes are failing.
|
|
||||||
|
|
||||||
## Test Notes
|
|
||||||
|
|
||||||
Focused verification completed successfully:
|
|
||||||
- `dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests/ZB.MOM.WW.LmxOpcUa.Tests.csproj --filter "FullyQualifiedName~MultiClientTests.MultipleClients_SubscribeToSameTag_AllReceiveDataChanges"`
|
|
||||||
- `dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests/ZB.MOM.WW.LmxOpcUa.Tests.csproj --filter "FullyQualifiedName~MxAccessClientMonitorTests.Monitor_ReconnectsOnDisconnect|FullyQualifiedName~MxAccessClientMonitorTests.Monitor_ProbeStale_ForcesReconnect"`
|
|
||||||
- `dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests/ZB.MOM.WW.LmxOpcUa.Tests.csproj --filter "FullyQualifiedName~ChangeDetectionServiceTests"`
|
|
||||||
|
|
||||||
I did not rerun the entire solution test suite in this pass. A previous full-solution run had timed out, so verification here was focused on the behavior touched by the prior fixes and the service-reliability paths reviewed above.
|
|
||||||
59
codereviews/solution_review_20260327.json
Normal file
59
codereviews/solution_review_20260327.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"title": "[P1] Synchronize dispatch lookups with address-space rebuilds",
|
||||||
|
"body": "The data-change dispatcher reads `_tagToVariableNode` and `_alarmInAlarmTags` here before taking `Lock`, while `BuildAddressSpace`, `SyncAddressSpace`, and `TearDownGobjects` mutate those same `Dictionary` instances under `Lock`. If a Galaxy rebuild lands while MXAccess is delivering changes, `TryGetValue` can observe concurrent mutation and throw; because these lookups sit outside the inner `try/catch`, the dispatch thread dies and all subscribed OPC UA items stop publishing until the service is restarted.",
|
||||||
|
"confidence_score": 0.98,
|
||||||
|
"priority": 1,
|
||||||
|
"code_location": {
|
||||||
|
"absolute_file_path": "C:\\Users\\dohertj2\\Desktop\\lmxopcua\\src\\ZB.MOM.WW.LmxOpcUa.Host\\OpcUa\\LmxNodeManager.cs",
|
||||||
|
"line_range": {
|
||||||
|
"start": 1516,
|
||||||
|
"end": 1530
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[P1] Guard subscription refcounts with the same mutex",
|
||||||
|
"body": "This block rewrites `_subscriptionRefCounts` while holding `Lock`, but the normal monitored-item paths (`SubscribeTag`, `UnsubscribeTag`, and `RestoreTransferredSubscriptions`) protect the same dictionary with `_lock` instead. A monitored-item create/delete that overlaps a rebuild can therefore race with the restore path here, losing counts or sending extra `SubscribeAsync`/`UnsubscribeAsync` calls. In a live system that leaves surviving subscriptions missing or leaked after a deploy even though the OPC UA client state never changed.",
|
||||||
|
"confidence_score": 0.93,
|
||||||
|
"priority": 1,
|
||||||
|
"code_location": {
|
||||||
|
"absolute_file_path": "C:\\Users\\dohertj2\\Desktop\\lmxopcua\\src\\ZB.MOM.WW.LmxOpcUa.Host\\OpcUa\\LmxNodeManager.cs",
|
||||||
|
"line_range": {
|
||||||
|
"start": 556,
|
||||||
|
"end": 557
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[P2] Skip duplicate runtime subscriptions for the same tag",
|
||||||
|
"body": "Once the client is connected, `SubscribeAsync` always opens a fresh COM item for the address instead of reusing an existing one. Callers can hit that through alarm auto-subscriptions, transferred-monitored-item recovery, or any repeated higher-level subscribe for the same tag. The new handle overwrites `_addressToHandle[address]`, so `UnsubscribeAsync` removes only the newest item while the original subscription keeps receiving callbacks until disconnect.",
|
||||||
|
"confidence_score": 0.97,
|
||||||
|
"priority": 2,
|
||||||
|
"code_location": {
|
||||||
|
"absolute_file_path": "C:\\Users\\dohertj2\\Desktop\\lmxopcua\\src\\ZB.MOM.WW.LmxOpcUa.Host\\MxAccess\\MxAccessClient.Subscription.cs",
|
||||||
|
"line_range": {
|
||||||
|
"start": 17,
|
||||||
|
"end": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "[P2] Detach MX callbacks before disposing the dispatch signal",
|
||||||
|
"body": "The node manager subscribes to `_mxAccessClient.OnTagValueChanged` in the constructor, but `Dispose(bool)` only stops the thread and disposes `_dataChangeSignal`. During `OpcUaService.Stop()`, the OPC UA server is stopped before the MXAccess client disconnects, so any runtime callback delivered in that window will call `OnMxAccessDataChange` and hit `Set()` on a disposed `AutoResetEvent`. That produces shutdown-time exceptions and keeps the dead node manager rooted by the event subscription.",
|
||||||
|
"confidence_score": 0.89,
|
||||||
|
"priority": 2,
|
||||||
|
"code_location": {
|
||||||
|
"absolute_file_path": "C:\\Users\\dohertj2\\Desktop\\lmxopcua\\src\\ZB.MOM.WW.LmxOpcUa.Host\\OpcUa\\LmxNodeManager.cs",
|
||||||
|
"line_range": {
|
||||||
|
"start": 1632,
|
||||||
|
"end": 1633
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"overall_correctness": "patch is incorrect",
|
||||||
|
"overall_explanation": "The solution has multiple runtime reliability issues in the MXAccess-to-OPC-UA bridge: address-space rebuilds race with live data dispatch, subscription bookkeeping is not synchronized consistently, and duplicate or stale subscriptions can be left behind. Those issues can break live publishing or leak runtime handles under ordinary reconnect and rebuild scenarios.",
|
||||||
|
"overall_confidence_score": 0.94
|
||||||
|
}
|
||||||
BIN
dashboard.JPG
BIN
dashboard.JPG
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB |
305
hda_plan.md
305
hda_plan.md
@@ -1,305 +0,0 @@
|
|||||||
# Alarm & History Detection Plan
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Galaxy attributes can be alarms (with `AlarmExtension` primitives) or historized (with `HistoryExtension` primitives). This plan documents how to detect these in the Galaxy Repository database and maps Galaxy alarm properties to OPC UA Alarms & Conditions concepts.
|
|
||||||
|
|
||||||
## 1. Detection in the Galaxy Repository
|
|
||||||
|
|
||||||
### Alarm Detection
|
|
||||||
|
|
||||||
An attribute is an alarm when a `primitive_instance` exists in the deployed package chain where:
|
|
||||||
- `primitive_instance.primitive_name` matches the `dynamic_attribute.attribute_name`
|
|
||||||
- `primitive_definition.primitive_name = 'AlarmExtension'`
|
|
||||||
|
|
||||||
Example: `TestMachine_001.TestAlarm001` has a `primitive_instance` named `TestAlarm001` with `primitive_definition.primitive_name = 'AlarmExtension'`.
|
|
||||||
|
|
||||||
### History Detection
|
|
||||||
|
|
||||||
Already implemented in the attributes queries. Same pattern but checking for `primitive_definition.primitive_name = 'HistoryExtension'`.
|
|
||||||
|
|
||||||
### Query Pattern
|
|
||||||
|
|
||||||
Both use the same EXISTS subquery against the deployed package chain:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CASE WHEN EXISTS (
|
|
||||||
SELECT 1 FROM deployed_package_chain dpc2
|
|
||||||
INNER JOIN primitive_instance pi
|
|
||||||
ON pi.package_id = dpc2.package_id
|
|
||||||
AND pi.primitive_name = da.attribute_name
|
|
||||||
INNER JOIN primitive_definition pd
|
|
||||||
ON pd.primitive_definition_id = pi.primitive_definition_id
|
|
||||||
AND pd.primitive_name = 'AlarmExtension' -- or 'HistoryExtension'
|
|
||||||
WHERE dpc2.gobject_id = dpc.gobject_id
|
|
||||||
) THEN 1 ELSE 0 END AS is_alarm
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Galaxy Alarm Properties (AlarmExtension)
|
|
||||||
|
|
||||||
The `AlarmExtension` primitive exposes 24 public attributes per alarm. These are already returned by the extended attributes query as primitive child attributes (e.g., `TestMachine_001.TestAlarm001.Acked`).
|
|
||||||
|
|
||||||
### Key Properties and Runtime Values
|
|
||||||
|
|
||||||
| Galaxy Attribute | Data Type | Runtime Example | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **InAlarm** | Boolean | `False` | Whether alarm condition is currently active |
|
|
||||||
| **Acked** | Boolean | `False` | Whether alarm has been acknowledged |
|
|
||||||
| **Condition** | Boolean | `False` | Raw condition value (input to alarm logic) |
|
|
||||||
| **ActiveAlarmState** | Boolean | `True` | Active state of alarm processing |
|
|
||||||
| **Priority** | Integer | `500` | Alarm priority (1-999, higher = more urgent) |
|
|
||||||
| **Category** | Enum | `1` (Discrete) | Alarm category type |
|
|
||||||
| **AlarmMode** | Enum | `1` (Enable) | Operational mode: 1=Enable, 2=Disable, 3=Silence |
|
|
||||||
| **AckMsg** | String | `""` | Acknowledgment message/comment |
|
|
||||||
| **TimeAlarmOn** | DateTime | | When alarm condition triggered |
|
|
||||||
| **TimeAlarmOff** | DateTime | | When alarm condition cleared |
|
|
||||||
| **TimeAlarmAcked** | DateTime | | When alarm was acknowledged |
|
|
||||||
| **AlarmInhibit** | Boolean | | Inhibit alarm processing |
|
|
||||||
| **AlarmShelved** | Boolean | `False` | Whether alarm is shelved |
|
|
||||||
| **AlarmShelveNode** | String | | Node that shelved the alarm |
|
|
||||||
| **AlarmShelveReason** | String | | Reason for shelving |
|
|
||||||
| **AlarmShelveUser** | String | | User who shelved |
|
|
||||||
| **AlarmShelveStartTime** | DateTime | | When shelve started |
|
|
||||||
| **AlarmShelveStopTime** | DateTime | | When shelve ends |
|
|
||||||
| **AlarmShelveCmd** | String | | Shelving command |
|
|
||||||
| **AlarmModeCmd** | Enum | | Command to change alarm mode |
|
|
||||||
| **AlarmSourceAttr** | Reference | | Source attribute reference |
|
|
||||||
| **DescAttrName** | String | | Descriptive attribute name |
|
|
||||||
| **Alarm.TimeDeadband** | ElapsedTime | | Time deadband for alarm |
|
|
||||||
|
|
||||||
### Alarm Enum Values
|
|
||||||
|
|
||||||
**AlarmMode**: Enable (1), Disable (2), Silence (3)
|
|
||||||
|
|
||||||
**Category**: Discrete (1), Value LoLo, Value Lo, Value Hi, Value HiHi, ROC, Deviation (and more)
|
|
||||||
|
|
||||||
## 3. Mapping Galaxy Alarm Properties to OPC UA
|
|
||||||
|
|
||||||
### OPC UA Alarm Type Hierarchy
|
|
||||||
|
|
||||||
```
|
|
||||||
ConditionType
|
|
||||||
└─ AcknowledgeableConditionType
|
|
||||||
└─ AlarmConditionType
|
|
||||||
├─ DiscreteAlarmType ← for Galaxy boolean alarms (Category=Discrete)
|
|
||||||
├─ OffNormalAlarmType ← alternative for boolean alarms
|
|
||||||
└─ LimitAlarmType ← for analog alarms (Hi/Lo/HiHi/LoLo)
|
|
||||||
├─ ExclusiveLimitAlarmType
|
|
||||||
└─ NonExclusiveLimitAlarmType
|
|
||||||
```
|
|
||||||
|
|
||||||
Galaxy boolean alarms (like TestAlarm001/002/003) map to **DiscreteAlarmType** or **OffNormalAlarmType**.
|
|
||||||
|
|
||||||
### Property Mapping
|
|
||||||
|
|
||||||
| Galaxy Property | OPC UA Alarm Property | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `InAlarm` | `ActiveState.Id` | Boolean: alarm is active |
|
|
||||||
| `Acked` | `AckedState.Id` | Boolean: alarm acknowledged |
|
|
||||||
| `Priority` | `Severity` | Galaxy 1-999 maps to OPC UA 1-1000 |
|
|
||||||
| `AckMsg` | `Comment` | Acknowledgment message |
|
|
||||||
| `Condition` | Source variable value | The boolean condition input |
|
|
||||||
| `AlarmMode` (Enable/Disable) | `EnabledState.Id` | Enable=true, Disable/Silence=false |
|
|
||||||
| `ActiveAlarmState` | `Retain` | Whether condition should be retained |
|
|
||||||
| `TimeAlarmOn` | `ActiveState.TransitionTime` | When alarm became active |
|
|
||||||
| `TimeAlarmOff` | `ActiveState.TransitionTime` | When alarm became inactive |
|
|
||||||
| `TimeAlarmAcked` | `AckedState.TransitionTime` | When alarm was acknowledged |
|
|
||||||
| `AlarmShelved` | `ShelvedState` (current state) | Maps to Unshelved/OneShotShelved/TimedShelved |
|
|
||||||
| `AlarmShelveStartTime` | `ShelvingState.UnshelveTime` | Computed from start/stop times |
|
|
||||||
| `Category` | `ConditionClassId` | Identifies condition class |
|
|
||||||
| `AlarmInhibit` | `SuppressedState.Id` | Alarm suppression |
|
|
||||||
| `DescAttrName` | `Message` | Description/message for alarm |
|
|
||||||
|
|
||||||
### Properties Not Available in Galaxy
|
|
||||||
|
|
||||||
These OPC UA properties have no direct Galaxy equivalent and would use defaults:
|
|
||||||
- `ConfirmedState` — Galaxy doesn't have a confirmed concept (default: true)
|
|
||||||
- `BranchId` — Galaxy doesn't support branching (default: null)
|
|
||||||
- `Quality` — Use the source variable's StatusCode
|
|
||||||
|
|
||||||
## 4. Implementation Approach
|
|
||||||
|
|
||||||
### OPC UA SDK Classes
|
|
||||||
|
|
||||||
- `AlarmConditionState` — main class for alarm nodes
|
|
||||||
- `TwoStateVariableType` — for ActiveState, AckedState, EnabledState, ShelvedState
|
|
||||||
- `ShelvedStateMachineType` — for shelving state management
|
|
||||||
|
|
||||||
### Key Implementation Steps
|
|
||||||
|
|
||||||
1. **Detect alarms in the query** — add `is_alarm` column to attributes queries (same pattern as `is_historized`)
|
|
||||||
2. **Create alarm condition nodes** — for attributes where `is_alarm = 1`, create an `AlarmConditionState` instead of a plain `BaseDataVariableState`
|
|
||||||
3. **Map properties** — subscribe to the Galaxy alarm sub-attributes (InAlarm, Acked, Priority, etc.) and update the OPC UA alarm state
|
|
||||||
4. **Event notifications** — when alarm state changes arrive via MXAccess `OnDataChange`, raise OPC UA alarm events via `ReportEvent()`
|
|
||||||
5. **Condition refresh** — implement `ConditionRefresh()` to send current alarm states to newly subscribing clients
|
|
||||||
6. **Acknowledge method** — implement the OPC UA `Acknowledge` method to write back to Galaxy via MXAccess
|
|
||||||
|
|
||||||
### Galaxy Alarm Types in the Database
|
|
||||||
|
|
||||||
51 alarm-related primitive definitions exist. The main ones relevant to OPC UA mapping:
|
|
||||||
|
|
||||||
| Galaxy Primitive | OPC UA Alarm Type |
|
|
||||||
|---|---|
|
|
||||||
| `AlarmExtension` (Boolean) | `DiscreteAlarmType` / `OffNormalAlarmType` |
|
|
||||||
| `AnalogExtension.LevelAlarms.Hi/HiHi/Lo/LoLo` | `ExclusiveLimitAlarmType` or `NonExclusiveLimitAlarmType` |
|
|
||||||
| `AnalogExtension.ROCAlarms` | `RateOfChangeAlarmType` |
|
|
||||||
| `AnalogExtension.DeviationAlarms` | `DeviationAlarmType` |
|
|
||||||
|
|
||||||
### Files to Modify
|
|
||||||
|
|
||||||
- `gr/queries/attributes_extended.sql` — add `is_alarm` column
|
|
||||||
- `gr/queries/attributes.sql` — add `is_alarm` column
|
|
||||||
- `src/.../Domain/GalaxyAttributeInfo.cs` — add `IsAlarm` property
|
|
||||||
- `src/.../GalaxyRepository/GalaxyRepositoryService.cs` — read `is_alarm` from query results
|
|
||||||
- `src/.../OpcUa/LmxNodeManager.cs` — create `AlarmConditionState` nodes for alarm attributes
|
|
||||||
- New: alarm state update handler mapping MXAccess data changes to OPC UA alarm events
|
|
||||||
- `tools/opcuacli-dotnet/Commands/AlarmsCommand.cs` — NEW CLI command
|
|
||||||
- `tools/opcuacli-dotnet/README.md` — add `alarms` command documentation
|
|
||||||
|
|
||||||
## 5. OPC UA CLI Tool — Alarms Command
|
|
||||||
|
|
||||||
Add an `alarms` command to `tools/opcuacli-dotnet/` for subscribing to and displaying OPC UA alarm events.
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Subscribe to all alarm events under a node (e.g., TestMachine_001)
|
|
||||||
dotnet run -- alarms -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001"
|
|
||||||
|
|
||||||
# Subscribe to all events under the root ZB node
|
|
||||||
dotnet run -- alarms -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=ZB"
|
|
||||||
|
|
||||||
# Subscribe to all server events (Server node)
|
|
||||||
dotnet run -- alarms -u opc.tcp://localhost:4840/LmxOpcUa
|
|
||||||
|
|
||||||
# Request a condition refresh to get current alarm states immediately
|
|
||||||
dotnet run -- alarms -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001" --refresh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Command Options
|
|
||||||
|
|
||||||
| Flag | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `-u, --url` | OPC UA server endpoint URL (required) |
|
|
||||||
| `-n, --node` | Node ID to monitor for events (default: Server node i=2253) |
|
|
||||||
| `--refresh` | Request a ConditionRefresh after subscribing to get current states |
|
|
||||||
| `-i, --interval` | Publishing interval in milliseconds (default: 1000) |
|
|
||||||
|
|
||||||
### Output Format
|
|
||||||
|
|
||||||
```
|
|
||||||
Subscribed to alarm events on ns=1;s=TestMachine_001 (interval: 1000ms). Press Ctrl+C to stop.
|
|
||||||
|
|
||||||
[2026-03-26T04:30:12.000Z] ALARM TestMachine_001.TestAlarm001
|
|
||||||
State: Active, Unacknowledged
|
|
||||||
Severity: 500
|
|
||||||
Message: Discrete alarm triggered
|
|
||||||
Source: ns=1;s=TestMachine_001.TestAlarm001
|
|
||||||
Retain: True
|
|
||||||
|
|
||||||
[2026-03-26T04:30:45.000Z] ALARM TestMachine_001.TestAlarm001
|
|
||||||
State: Active, Acknowledged
|
|
||||||
Severity: 500
|
|
||||||
Message: Discrete alarm triggered
|
|
||||||
AckUser: operator1
|
|
||||||
|
|
||||||
[2026-03-26T04:31:02.000Z] ALARM TestMachine_001.TestAlarm001
|
|
||||||
State: Inactive, Acknowledged
|
|
||||||
Severity: 500
|
|
||||||
Retain: False
|
|
||||||
```
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
New file: `tools/opcuacli-dotnet/Commands/AlarmsCommand.cs`
|
|
||||||
|
|
||||||
OPC UA alarm events are received through event-type monitored items, not regular data-change subscriptions. The key differences from the `subscribe` command:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Create an event monitored item (not a data-change item)
|
|
||||||
var item = new MonitoredItem(subscription.DefaultItem)
|
|
||||||
{
|
|
||||||
StartNodeId = nodeId,
|
|
||||||
DisplayName = "AlarmMonitor",
|
|
||||||
SamplingInterval = interval,
|
|
||||||
NodeClass = NodeClass.Object,
|
|
||||||
// Subscribe to events, not data changes
|
|
||||||
AttributeId = Attributes.EventNotifier,
|
|
||||||
// Select which event fields to return
|
|
||||||
Filter = CreateEventFilter()
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Event Filter
|
|
||||||
|
|
||||||
Select the standard alarm fields to display:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private static EventFilter CreateEventFilter()
|
|
||||||
{
|
|
||||||
var filter = new EventFilter();
|
|
||||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, "EventId");
|
|
||||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, "EventType");
|
|
||||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, "SourceName");
|
|
||||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, "Time");
|
|
||||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, "Message");
|
|
||||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, "Severity");
|
|
||||||
filter.AddSelectClause(ObjectTypeIds.ConditionType, "ConditionName");
|
|
||||||
filter.AddSelectClause(ObjectTypeIds.ConditionType, "Retain");
|
|
||||||
filter.AddSelectClause(ObjectTypeIds.AcknowledgeableConditionType, "AckedState/Id");
|
|
||||||
filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "ActiveState/Id");
|
|
||||||
filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "EnabledState/Id");
|
|
||||||
filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "SuppressedOrShelved");
|
|
||||||
return filter;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Event Notification Handler
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
item.Notification += (monitoredItem, e) =>
|
|
||||||
{
|
|
||||||
if (e.NotificationValue is EventFieldList eventFields)
|
|
||||||
{
|
|
||||||
var time = eventFields.EventFields[3].Value as DateTime?;
|
|
||||||
var sourceName = eventFields.EventFields[2].Value as string;
|
|
||||||
var message = (eventFields.EventFields[4].Value as LocalizedText)?.Text;
|
|
||||||
var severity = eventFields.EventFields[5].Value as ushort?;
|
|
||||||
var ackedState = eventFields.EventFields[8].Value as bool?;
|
|
||||||
var activeState = eventFields.EventFields[9].Value as bool?;
|
|
||||||
var retain = eventFields.EventFields[7].Value as bool?;
|
|
||||||
|
|
||||||
var stateDesc = FormatAlarmState(activeState, ackedState);
|
|
||||||
Console.WriteLine($"[{time:O}] ALARM {sourceName}");
|
|
||||||
Console.WriteLine($" State: {stateDesc}");
|
|
||||||
Console.WriteLine($" Severity: {severity}");
|
|
||||||
if (!string.IsNullOrEmpty(message))
|
|
||||||
Console.WriteLine($" Message: {message}");
|
|
||||||
Console.WriteLine($" Retain: {retain}");
|
|
||||||
Console.WriteLine();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Condition Refresh
|
|
||||||
|
|
||||||
When `--refresh` is specified, call `ConditionRefresh` after creating the subscription to receive the current state of all active alarms:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
if (refresh)
|
|
||||||
{
|
|
||||||
await subscription.ConditionRefreshAsync();
|
|
||||||
await console.Output.WriteLineAsync("Condition refresh requested.");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### State Formatting
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
static string FormatAlarmState(bool? active, bool? acked)
|
|
||||||
{
|
|
||||||
var activePart = active == true ? "Active" : "Inactive";
|
|
||||||
var ackedPart = acked == true ? "Acknowledged" : "Unacknowledged";
|
|
||||||
return $"{activePart}, {ackedPart}";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
# OPC UA Historical Data Access Plan
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Galaxy attributes with `HistoryExtension` primitives are historized by the Wonderware Historian. The Historian exposes its data via SQL queries against the `Runtime` database. This plan documents how to implement OPC UA Historical Data Access (HDA) so OPC UA clients can read historical values through the server.
|
|
||||||
|
|
||||||
## 1. Wonderware Historian Data Source
|
|
||||||
|
|
||||||
### Connection
|
|
||||||
|
|
||||||
- **Database**: `Runtime` on `localhost` (Windows Auth)
|
|
||||||
- **Constraint**: History views require a `WHERE TagName='...'` clause — queries without a tag filter will fail.
|
|
||||||
|
|
||||||
### History View Schema (31 columns)
|
|
||||||
|
|
||||||
Key columns for OPC UA HDA:
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `DateTime` | datetime2 | Timestamp of the value |
|
|
||||||
| `TagName` | nvarchar(256) | Galaxy tag reference (e.g., `TestMachine_001.TestHistoryValue`) |
|
|
||||||
| `Value` | float | Numeric value |
|
|
||||||
| `vValue` | nvarchar(4000) | String representation of value |
|
|
||||||
| `Quality` | tinyint | Quality code (0=Good, 1=Bad, 133=Uncertain) |
|
|
||||||
| `QualityDetail` | int | Detailed quality (192=Good) |
|
|
||||||
| `OPCQuality` | int | OPC-style quality code |
|
|
||||||
|
|
||||||
### Raw Data Query
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT DateTime, Value, vValue, Quality, QualityDetail
|
|
||||||
FROM Runtime.dbo.History
|
|
||||||
WHERE TagName = 'TestMachine_001.TestHistoryValue'
|
|
||||||
AND DateTime BETWEEN @StartTime AND @EndTime
|
|
||||||
ORDER BY DateTime
|
|
||||||
```
|
|
||||||
|
|
||||||
### Aggregate Data (AnalogSummaryHistory)
|
|
||||||
|
|
||||||
The Historian provides pre-calculated aggregates via the `AnalogSummaryHistory` view:
|
|
||||||
|
|
||||||
| Column | Description |
|
|
||||||
|---|---|
|
|
||||||
| `StartDateTime` | Start of aggregate interval |
|
|
||||||
| `EndDateTime` | End of aggregate interval |
|
|
||||||
| `First` | First value in interval |
|
|
||||||
| `Last` | Last value in interval |
|
|
||||||
| `Minimum` | Minimum value |
|
|
||||||
| `Maximum` | Maximum value |
|
|
||||||
| `Average` | Average value |
|
|
||||||
| `StdDev` | Standard deviation |
|
|
||||||
| `Integral` | Time-weighted integral |
|
|
||||||
| `ValueCount` | Number of values |
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT StartDateTime, EndDateTime, Average, Minimum, Maximum, ValueCount
|
|
||||||
FROM Runtime.dbo.AnalogSummaryHistory
|
|
||||||
WHERE TagName = 'TestMachine_001.TestHistoryValue'
|
|
||||||
AND StartDateTime BETWEEN @StartTime AND @EndTime
|
|
||||||
AND wwResolution = @IntervalMs
|
|
||||||
```
|
|
||||||
|
|
||||||
### Retrieval Modes
|
|
||||||
|
|
||||||
| Mode | Description |
|
|
||||||
|---|---|
|
|
||||||
| `DELTA` | Change-based retrieval (default) — returns values when they changed |
|
|
||||||
| `CYCLIC` | Periodic sampling — returns interpolated values at fixed intervals |
|
|
||||||
|
|
||||||
### Quality Mapping
|
|
||||||
|
|
||||||
| Historian Quality | OPC UA StatusCode |
|
|
||||||
|---|---|
|
|
||||||
| 0 (Good) | `Good` (0x00000000) |
|
|
||||||
| 1 (Bad) | `Bad` (0x80000000) |
|
|
||||||
| 133 (Uncertain) | `Uncertain` (0x40000000) |
|
|
||||||
|
|
||||||
### Test Data
|
|
||||||
|
|
||||||
Tag: `TestMachine_001.TestHistoryValue` (Analog, Integer)
|
|
||||||
- 4 records from 2026-03-26 00:44 to 01:09
|
|
||||||
- Values: 0, 3, 4, 7, 9
|
|
||||||
- InterpolationType: STAIRSTEP
|
|
||||||
|
|
||||||
## 2. OPC UA HDA Implementation
|
|
||||||
|
|
||||||
### Marking Variables as Historized
|
|
||||||
|
|
||||||
For attributes where `is_historized = 1` from the Galaxy query:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
variable.Historizing = true;
|
|
||||||
variable.AccessLevel |= AccessLevels.HistoryRead;
|
|
||||||
variable.UserAccessLevel |= AccessLevels.HistoryRead;
|
|
||||||
```
|
|
||||||
|
|
||||||
This tells OPC UA clients the variable supports `HistoryRead` requests.
|
|
||||||
|
|
||||||
### Server-Side Handler
|
|
||||||
|
|
||||||
Override `HistoryRead` on `LmxNodeManager` (inherits from `CustomNodeManager2`):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public override void HistoryRead(
|
|
||||||
OperationContext context,
|
|
||||||
HistoryReadDetails details,
|
|
||||||
TimestampsToReturn timestampsToReturn,
|
|
||||||
bool releaseContinuationPoints,
|
|
||||||
IList<HistoryReadValueId> nodesToRead,
|
|
||||||
IList<HistoryReadResult> results,
|
|
||||||
IList<ServiceResult> errors)
|
|
||||||
```
|
|
||||||
|
|
||||||
Dispatch based on `details` type:
|
|
||||||
- `ReadRawModifiedDetails` → `HistoryReadRaw` → query `Runtime.dbo.History`
|
|
||||||
- `ReadProcessedDetails` → `HistoryReadProcessed` → query `Runtime.dbo.AnalogSummaryHistory`
|
|
||||||
- `ReadAtTimeDetails` → `HistoryReadAtTime` → query with `wwRetrievalMode = 'Cyclic'`
|
|
||||||
|
|
||||||
### ReadRaw Implementation
|
|
||||||
|
|
||||||
Map `HistoryReadRawModifiedDetails` to a Historian SQL query:
|
|
||||||
|
|
||||||
| OPC UA Parameter | SQL Mapping |
|
|
||||||
|---|---|
|
|
||||||
| `StartTime` | `DateTime >= @StartTime` |
|
|
||||||
| `EndTime` | `DateTime <= @EndTime` |
|
|
||||||
| `NumValuesPerNode` | `TOP @NumValues` |
|
|
||||||
| `ReturnBounds` | Include one value before StartTime and one after EndTime |
|
|
||||||
|
|
||||||
Result: populate `HistoryData` with `DataValue` list:
|
|
||||||
```csharp
|
|
||||||
new DataValue
|
|
||||||
{
|
|
||||||
Value = row.Value,
|
|
||||||
SourceTimestamp = row.DateTime,
|
|
||||||
StatusCode = MapQuality(row.Quality)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ReadProcessed Implementation
|
|
||||||
|
|
||||||
Map `HistoryReadProcessedDetails` to `AnalogSummaryHistory`:
|
|
||||||
|
|
||||||
| OPC UA Aggregate | Historian Column |
|
|
||||||
|---|---|
|
|
||||||
| `Average` | `Average` |
|
|
||||||
| `Minimum` | `Minimum` |
|
|
||||||
| `Maximum` | `Maximum` |
|
|
||||||
| `Count` | `ValueCount` |
|
|
||||||
| `Start` | `First` |
|
|
||||||
| `End` | `Last` |
|
|
||||||
| `StandardDeviationPopulation` | `StdDev` |
|
|
||||||
|
|
||||||
`ProcessingInterval` maps to `wwResolution` (milliseconds).
|
|
||||||
|
|
||||||
### Continuation Points for Paging
|
|
||||||
|
|
||||||
When `NumValuesPerNode` limits the result:
|
|
||||||
|
|
||||||
1. Query `NumValuesPerNode + 1` rows
|
|
||||||
2. If more exist, save a continuation point (store last timestamp + query params)
|
|
||||||
3. Return `StatusCodes.GoodMoreData` with the continuation point
|
|
||||||
4. On next request, restore the continuation point and resume from last timestamp
|
|
||||||
|
|
||||||
Use `Session.SaveHistoryContinuationPoint()` / `RestoreHistoryContinuationPoint()` to manage state.
|
|
||||||
|
|
||||||
### Tag Name Resolution
|
|
||||||
|
|
||||||
The `FullTagReference` stored on each variable node (e.g., `TestMachine_001.TestHistoryValue`) is exactly the `TagName` used in the Historian query — no translation needed.
|
|
||||||
|
|
||||||
## 3. Galaxy Repository Detection
|
|
||||||
|
|
||||||
Already implemented: `is_historized` column in the attributes queries detects `HistoryExtension` primitives in the deployed package chain.
|
|
||||||
|
|
||||||
## 4. Implementation Steps
|
|
||||||
|
|
||||||
### Phase 1: Mark historized nodes
|
|
||||||
- Read `is_historized` from query results into `GalaxyAttributeInfo`
|
|
||||||
- In `LmxNodeManager.CreateAttributeVariable`, set `Historizing = true` and add `HistoryRead` to `AccessLevel`
|
|
||||||
|
|
||||||
### Phase 2: Historian data source
|
|
||||||
- New class: `HistorianDataSource` — executes SQL queries against `Runtime.dbo.History` and `AnalogSummaryHistory`
|
|
||||||
- Connection string configurable in `appsettings.json`
|
|
||||||
- Parameterized queries only (no dynamic SQL)
|
|
||||||
|
|
||||||
### Phase 3: HistoryRead handler
|
|
||||||
- Override `HistoryRead` on `LmxNodeManager`
|
|
||||||
- Implement `HistoryReadRaw` — query `History` view, map results to `HistoryData`
|
|
||||||
- Implement `HistoryReadProcessed` — query `AnalogSummaryHistory`, map aggregates
|
|
||||||
- Implement continuation points for large result sets
|
|
||||||
|
|
||||||
### Phase 4: Testing
|
|
||||||
- Unit tests for quality mapping, tag name resolution, SQL parameter building
|
|
||||||
- Integration test: create a historized variable, verify `Historizing = true` and `HistoryRead` access level
|
|
||||||
- Manual test: use OPC UA client to read historical data from deployed server
|
|
||||||
|
|
||||||
## 5. OPC UA CLI Tool — History Command
|
|
||||||
|
|
||||||
Add a `historyread` command to `tools/opcuacli-dotnet/` for manual testing of HDA.
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Read raw history (last 24 hours)
|
|
||||||
dotnet run -- historyread -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.TestHistoryValue"
|
|
||||||
|
|
||||||
# Read raw history with explicit time range
|
|
||||||
dotnet run -- historyread -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.TestHistoryValue" --start "2026-03-25" --end "2026-03-30"
|
|
||||||
|
|
||||||
# Read with max values limit
|
|
||||||
dotnet run -- historyread -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.TestHistoryValue" --start "2026-03-25" --end "2026-03-30" --max 100
|
|
||||||
|
|
||||||
# Read processed/aggregate history (1-hour intervals, Average)
|
|
||||||
dotnet run -- historyread -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.TestHistoryValue" --start "2026-03-25" --end "2026-03-30" --aggregate Average --interval 3600000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Command Options
|
|
||||||
|
|
||||||
| Flag | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `-u, --url` | OPC UA server endpoint URL (required) |
|
|
||||||
| `-n, --node` | Node ID to read history for (required) |
|
|
||||||
| `--start` | Start time, ISO 8601 or date string (default: 24 hours ago) |
|
|
||||||
| `--end` | End time, ISO 8601 or date string (default: now) |
|
|
||||||
| `--max` | Maximum number of values to return (default: 1000) |
|
|
||||||
| `--aggregate` | Aggregate function: Average, Minimum, Maximum, Count (default: none = raw) |
|
|
||||||
| `--interval` | Processing interval in milliseconds for aggregates (default: 3600000 = 1 hour) |
|
|
||||||
|
|
||||||
### Output Format
|
|
||||||
|
|
||||||
**Raw history:**
|
|
||||||
```
|
|
||||||
History for ns=1;s=TestMachine_001.TestHistoryValue (2026-03-25 → 2026-03-30)
|
|
||||||
|
|
||||||
Timestamp Value Status
|
|
||||||
2026-03-26T00:44:03.000Z 0 Good
|
|
||||||
2026-03-26T00:52:17.000Z 3 Good
|
|
||||||
2026-03-26T01:01:44.000Z 7 Good
|
|
||||||
2026-03-26T01:09:00.000Z 9 Good
|
|
||||||
|
|
||||||
4 values returned.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Aggregate history:**
|
|
||||||
```
|
|
||||||
History for ns=1;s=TestMachine_001.TestHistoryValue (Average, interval=3600000ms)
|
|
||||||
|
|
||||||
Timestamp Value Status
|
|
||||||
2026-03-26T00:00:00.000Z 4.75 Good
|
|
||||||
|
|
||||||
1 values returned.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
New file: `tools/opcuacli-dotnet/Commands/HistoryReadCommand.cs`
|
|
||||||
|
|
||||||
Uses the OPC UA client SDK's `Session.ReadRawHistory` and `Session.ReadProcessedHistory` methods (or `HistoryReadAsync` with appropriate `HistoryReadDetails`):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Raw read
|
|
||||||
var details = new ReadRawModifiedDetails
|
|
||||||
{
|
|
||||||
StartTime = startTime,
|
|
||||||
EndTime = endTime,
|
|
||||||
NumValuesPerNode = (uint)maxValues,
|
|
||||||
IsReadModified = false,
|
|
||||||
ReturnBounds = false
|
|
||||||
};
|
|
||||||
|
|
||||||
// Processed read
|
|
||||||
var details = new ReadProcessedDetails
|
|
||||||
{
|
|
||||||
StartTime = startTime,
|
|
||||||
EndTime = endTime,
|
|
||||||
ProcessingInterval = intervalMs,
|
|
||||||
AggregateType = new NodeIdCollection { aggregateNodeId }
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Follow the same pattern as existing commands: use `OpcUaHelper.ConnectAsync()`, parse NodeId, call history read, print results.
|
|
||||||
|
|
||||||
### Continuation Point Handling
|
|
||||||
|
|
||||||
If the server returns `GoodMoreData` with a continuation point, automatically follow up with subsequent requests until all data is retrieved or `--max` is reached.
|
|
||||||
|
|
||||||
### README Update
|
|
||||||
|
|
||||||
Add `historyread` section to `tools/opcuacli-dotnet/README.md` documenting the new command.
|
|
||||||
|
|
||||||
## 6. Files to Modify/Create
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|---|---|
|
|
||||||
| `src/.../Domain/GalaxyAttributeInfo.cs` | Add `IsHistorized` property |
|
|
||||||
| `src/.../GalaxyRepository/GalaxyRepositoryService.cs` | Read `is_historized` column |
|
|
||||||
| `src/.../OpcUa/LmxNodeManager.cs` | Set `Historizing`/`AccessLevel` for historized nodes; override `HistoryRead` |
|
|
||||||
| `src/.../Configuration/HistorianConfiguration.cs` | NEW — connection string, query timeout |
|
|
||||||
| `src/.../Historian/HistorianDataSource.cs` | NEW — SQL queries against Runtime DB |
|
|
||||||
| `appsettings.json` | Add `Historian` section with connection string |
|
|
||||||
| `tools/opcuacli-dotnet/Commands/HistoryReadCommand.cs` | NEW — `historyread` CLI command |
|
|
||||||
| `tools/opcuacli-dotnet/README.md` | Add `historyread` command documentation |
|
|
||||||
|
|
||||||
## 6. Configuration
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"Historian": {
|
|
||||||
"ConnectionString": "Server=localhost;Database=Runtime;Integrated Security=true;",
|
|
||||||
"CommandTimeoutSeconds": 30,
|
|
||||||
"MaxValuesPerRead": 10000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
# Partial Address Space Update Plan
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
When the Galaxy detects a new deployment (`time_of_last_deploy` changes), the server performs a **full rebuild**: unsubscribes all MXAccess tags, deletes all OPC UA nodes, reconstructs the entire address space, then re-subscribes. This disrupts all connected clients even if only one object changed.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Replace the full rebuild with a **subtree-level sync**: detect which Galaxy objects changed, tear down and rebuild only those subtrees, and leave everything else untouched.
|
|
||||||
|
|
||||||
## Current Flow (Full Rebuild)
|
|
||||||
|
|
||||||
```
|
|
||||||
ChangeDetectionService polls galaxy.time_of_last_deploy
|
|
||||||
→ timestamp changed
|
|
||||||
→ RebuildAddressSpace(newHierarchy, newAttributes)
|
|
||||||
1. Unsubscribe ALL MXAccess tags
|
|
||||||
2. Delete ALL OPC UA nodes
|
|
||||||
3. Clear all dictionaries
|
|
||||||
4. BuildAddressSpace() from scratch
|
|
||||||
5. Re-subscribe surviving tags
|
|
||||||
```
|
|
||||||
|
|
||||||
## Proposed Flow (Subtree Sync)
|
|
||||||
|
|
||||||
```
|
|
||||||
ChangeDetectionService polls galaxy.time_of_last_deploy
|
|
||||||
→ timestamp changed
|
|
||||||
→ SyncAddressSpace(newHierarchy, newAttributes)
|
|
||||||
1. Compare old vs new by GobjectId
|
|
||||||
2. Identify changed gobjects (added, removed, or any field/attribute difference)
|
|
||||||
3. Expand changed set to include child gobjects (subtree)
|
|
||||||
4. Tear down changed subtrees (delete nodes, unsubscribe, remove alarm tracking)
|
|
||||||
5. Rebuild changed subtrees using existing BuildAddressSpace logic
|
|
||||||
6. Update cache
|
|
||||||
```
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### 1. Cache Previous State
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private List<GalaxyObjectInfo>? _lastHierarchy;
|
|
||||||
private List<GalaxyAttributeInfo>? _lastAttributes;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Detect Changed GobjectIds
|
|
||||||
|
|
||||||
Compare old vs new to find which gobjects have any difference:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
static HashSet<int> FindChangedGobjectIds(
|
|
||||||
List<GalaxyObjectInfo> oldH, List<GalaxyAttributeInfo> oldA,
|
|
||||||
List<GalaxyObjectInfo> newH, List<GalaxyAttributeInfo> newA)
|
|
||||||
```
|
|
||||||
|
|
||||||
A gobject is "changed" if any of these differ:
|
|
||||||
- **Added**: gobject_id exists in new but not old
|
|
||||||
- **Removed**: gobject_id exists in old but not new
|
|
||||||
- **Object modified**: any field differs (TagName, BrowseName, ParentGobjectId, IsArea, ContainedName)
|
|
||||||
- **Attributes modified**: the set of attributes for that gobject_id differs (count, or any attribute field changed)
|
|
||||||
|
|
||||||
### 3. Expand to Subtrees
|
|
||||||
|
|
||||||
If a parent object changed, its children must also be rebuilt (they may reference the parent node). Expand the changed set:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Walk children: if gobject X changed, all gobjects with ParentGobjectId == X are also changed
|
|
||||||
static HashSet<int> ExpandToSubtrees(HashSet<int> changed, List<GalaxyObjectInfo> hierarchy)
|
|
||||||
```
|
|
||||||
|
|
||||||
This is recursive — if TestArea changed, TestMachine_001, DelmiaReceiver_001, and MESReceiver_001 all get rebuilt.
|
|
||||||
|
|
||||||
### 4. Tear Down Changed Subtrees
|
|
||||||
|
|
||||||
For each changed gobject_id:
|
|
||||||
- Find all variable nodes owned by this gobject (from `_tagToVariableNode` by matching tag prefix)
|
|
||||||
- Unsubscribe active MXAccess subscriptions for those tags
|
|
||||||
- Remove alarm tracking entries for those tags
|
|
||||||
- Delete the variable nodes from OPC UA
|
|
||||||
- Delete the object/folder node itself
|
|
||||||
- Remove from all dictionaries
|
|
||||||
|
|
||||||
### 5. Rebuild Changed Subtrees
|
|
||||||
|
|
||||||
Reuse existing code:
|
|
||||||
- Filter `newHierarchy` and `newAttributes` to only the changed gobject_ids
|
|
||||||
- Run the same node creation logic (topological sort, CreateFolder/CreateObject, CreateAttributeVariable, alarm tracking)
|
|
||||||
- The parent nodes for changed subtrees already exist (unchanged parents stay in place)
|
|
||||||
- If a parent was also removed (whole subtree removed), skip — children under root folder
|
|
||||||
|
|
||||||
### 6. SyncAddressSpace Method
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public void SyncAddressSpace(List<GalaxyObjectInfo> newHierarchy, List<GalaxyAttributeInfo> newAttributes)
|
|
||||||
{
|
|
||||||
lock (Lock)
|
|
||||||
{
|
|
||||||
if (_lastHierarchy == null)
|
|
||||||
{
|
|
||||||
BuildAddressSpace(newHierarchy, newAttributes);
|
|
||||||
_lastHierarchy = newHierarchy;
|
|
||||||
_lastAttributes = newAttributes;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var changedIds = FindChangedGobjectIds(_lastHierarchy, _lastAttributes, newHierarchy, newAttributes);
|
|
||||||
|
|
||||||
if (changedIds.Count == 0)
|
|
||||||
{
|
|
||||||
Log.Information("No address space changes detected");
|
|
||||||
_lastHierarchy = newHierarchy;
|
|
||||||
_lastAttributes = newAttributes;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand to include child subtrees
|
|
||||||
changedIds = ExpandToSubtrees(changedIds, _lastHierarchy);
|
|
||||||
changedIds = ExpandToSubtrees(changedIds, newHierarchy);
|
|
||||||
|
|
||||||
Log.Information("Incremental sync: {Count} gobjects changed", changedIds.Count);
|
|
||||||
|
|
||||||
// Tear down changed subtrees
|
|
||||||
TearDownGobjects(changedIds);
|
|
||||||
|
|
||||||
// Rebuild changed subtrees from new data
|
|
||||||
var changedHierarchy = newHierarchy.Where(h => changedIds.Contains(h.GobjectId)).ToList();
|
|
||||||
var changedAttributes = newAttributes.Where(a => changedIds.Contains(a.GobjectId)).ToList();
|
|
||||||
BuildSubtree(changedHierarchy, changedAttributes);
|
|
||||||
|
|
||||||
_lastHierarchy = newHierarchy;
|
|
||||||
_lastAttributes = newAttributes;
|
|
||||||
|
|
||||||
Log.Information("Incremental sync complete: {Objects} objects, {Variables} variables, {Alarms} alarms",
|
|
||||||
ObjectNodeCount, VariableNodeCount, _alarmInAlarmTags.Count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. TearDownGobjects
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private void TearDownGobjects(HashSet<int> gobjectIds)
|
|
||||||
{
|
|
||||||
// Collect tag references owned by these gobjects
|
|
||||||
// (match by TagName prefix from hierarchy)
|
|
||||||
// For each: unsubscribe, remove alarm tracking, delete node, remove from dictionaries
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Key: need a way to map gobject_id → set of tag references. Options:
|
|
||||||
- Store a `_gobjectIdToTagRefs` dictionary during build
|
|
||||||
- Or derive from `_tagToVariableNode` keys + hierarchy TagName matching
|
|
||||||
|
|
||||||
### 8. BuildSubtree
|
|
||||||
|
|
||||||
Reuse the same logic as `BuildAddressSpace` but:
|
|
||||||
- Only process the filtered hierarchy/attributes
|
|
||||||
- Parent nodes for the subtree roots already exist in `nodeMap` (they're unchanged)
|
|
||||||
- Need access to the existing `nodeMap` — either keep it as a field or rebuild from `PredefinedNodes`
|
|
||||||
|
|
||||||
This means `nodeMap` (currently local to `BuildAddressSpace`) should become a class field `_nodeMap`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private readonly Dictionary<int, NodeState> _nodeMap = new();
|
|
||||||
```
|
|
||||||
|
|
||||||
## What Stays the Same
|
|
||||||
|
|
||||||
- Nodes for unchanged gobjects → untouched
|
|
||||||
- MXAccess subscriptions for unchanged tags → untouched
|
|
||||||
- Alarm tracking for unchanged alarms → untouched
|
|
||||||
- OPC UA client subscriptions on unchanged nodes → uninterrupted
|
|
||||||
- `_pendingDataChanges` queue → continues processing (DispatchLoop skips missing nodes gracefully)
|
|
||||||
|
|
||||||
## Edge Cases
|
|
||||||
|
|
||||||
| Case | Handling |
|
|
||||||
|---|---|
|
|
||||||
| First build (no cache) | Full `BuildAddressSpace` |
|
|
||||||
| No changes detected | Log and skip |
|
|
||||||
| Object removed | Tear down subtree, children become orphaned → also removed |
|
|
||||||
| Object added | Build new subtree under existing parent |
|
|
||||||
| Object re-parented | Both old and new parent subtrees detected as changed → both rebuilt |
|
|
||||||
| All objects changed | Equivalent to full rebuild (acceptable) |
|
|
||||||
| Root folder | Never torn down — only child subtrees |
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Unit Tests (`AddressSpaceDiffTests`)
|
|
||||||
- `FindChangedGobjectIds` — verify detection of added, removed, modified objects
|
|
||||||
- `FindChangedGobjectIds` — verify attribute changes trigger gobject as changed
|
|
||||||
- `ExpandToSubtrees` — verify children are included
|
|
||||||
|
|
||||||
### Integration Tests (`IncrementalSyncTests`)
|
|
||||||
- Add object → appears in browse, existing subscriptions unaffected
|
|
||||||
- Remove object → disappears, subscriptions on surviving nodes continue
|
|
||||||
- Modify attribute on one object → only that subtree rebuilds, others untouched
|
|
||||||
- Verify subscription continuity: subscribe to node on Object A, modify Object B, subscription on A still delivers data
|
|
||||||
|
|
||||||
## Files to Create/Modify
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|---|---|
|
|
||||||
| `src/.../OpcUa/LmxNodeManager.cs` | Add `SyncAddressSpace`, `TearDownGobjects`, `BuildSubtree`; promote `nodeMap` to field; cache `_lastHierarchy`/`_lastAttributes` |
|
|
||||||
| `src/.../OpcUa/AddressSpaceDiff.cs` | NEW — `FindChangedGobjectIds`, `ExpandToSubtrees` (static helpers) |
|
|
||||||
| `tests/.../OpcUa/AddressSpaceDiffTests.cs` | NEW — unit tests for diff logic |
|
|
||||||
| `tests/.../Integration/IncrementalSyncTests.cs` | NEW — integration tests |
|
|
||||||
|
|
||||||
## Comparison: Full Rebuild vs Subtree Sync
|
|
||||||
|
|
||||||
| Aspect | Full Rebuild | Subtree Sync |
|
|
||||||
|---|---|---|
|
|
||||||
| Scope of disruption | All nodes, all clients | Only changed subtrees |
|
|
||||||
| MXAccess churn | Unsubscribe/resubscribe all | Only changed tags |
|
|
||||||
| Lock duration | Long (rebuild everything) | Short (rebuild subset) |
|
|
||||||
| Complexity | Simple (clear + build) | Moderate (diff + selective rebuild) |
|
|
||||||
| Correctness risk | Low (clean slate) | Medium (must handle orphans, partial state) |
|
|
||||||
| Fallback | N/A | Fall back to full rebuild on error |
|
|
||||||
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
{
|
{
|
||||||
_storedSubscriptions[fullTagReference] = callback;
|
_storedSubscriptions[fullTagReference] = callback;
|
||||||
if (_state != ConnectionState.Connected) return;
|
if (_state != ConnectionState.Connected) return;
|
||||||
|
if (_addressToHandle.ContainsKey(fullTagReference)) return;
|
||||||
|
|
||||||
await SubscribeInternalAsync(fullTagReference);
|
await SubscribeInternalAsync(fullTagReference);
|
||||||
}
|
}
|
||||||
@@ -56,6 +57,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
|
|
||||||
private async Task SubscribeInternalAsync(string address)
|
private async Task SubscribeInternalAsync(string address)
|
||||||
{
|
{
|
||||||
|
if (_addressToHandle.ContainsKey(address))
|
||||||
|
return;
|
||||||
|
|
||||||
using var scope = _metrics.BeginOperation("Subscribe");
|
using var scope = _metrics.BeginOperation("Subscribe");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -66,8 +70,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.MxAccess
|
|||||||
return h;
|
return h;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var registeredHandle = _addressToHandle.GetOrAdd(address, itemHandle);
|
||||||
|
if (registeredHandle != itemHandle)
|
||||||
|
{
|
||||||
|
await _staThread.RunAsync(() =>
|
||||||
|
{
|
||||||
|
_proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
|
||||||
|
_proxy.RemoveItem(_connectionHandle, itemHandle);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_handleToAddress[itemHandle] = address;
|
_handleToAddress[itemHandle] = address;
|
||||||
_addressToHandle[address] = itemHandle;
|
|
||||||
Log.Debug("Subscribed to {Address} (handle={Handle})", address, itemHandle);
|
Log.Debug("Subscribed to {Address} (handle={Handle})", address, itemHandle);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
private readonly Dictionary<string, BaseDataVariableState> _tagToVariableNode = new Dictionary<string, BaseDataVariableState>(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, BaseDataVariableState> _tagToVariableNode = new Dictionary<string, BaseDataVariableState>(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, TagMetadata> _tagMetadata = new Dictionary<string, TagMetadata>(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, TagMetadata> _tagMetadata = new Dictionary<string, TagMetadata>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private readonly object _lock = new object();
|
|
||||||
private IDictionary<NodeId, IList<IReference>>? _externalReferences;
|
private IDictionary<NodeId, IList<IReference>>? _externalReferences;
|
||||||
|
|
||||||
// Data change dispatch queue: decouples MXAccess STA callbacks from OPC UA framework Lock
|
// Data change dispatch queue: decouples MXAccess STA callbacks from OPC UA framework Lock
|
||||||
@@ -42,6 +41,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
private readonly AutoResetEvent _dataChangeSignal = new AutoResetEvent(false);
|
private readonly AutoResetEvent _dataChangeSignal = new AutoResetEvent(false);
|
||||||
private Thread? _dispatchThread;
|
private Thread? _dispatchThread;
|
||||||
private volatile bool _dispatchRunning;
|
private volatile bool _dispatchRunning;
|
||||||
|
private volatile bool _dispatchDisposed;
|
||||||
|
|
||||||
// Dispatch queue metrics
|
// Dispatch queue metrics
|
||||||
private long _totalMxChangeEvents;
|
private long _totalMxChangeEvents;
|
||||||
@@ -1397,7 +1397,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to subscribe.</param>
|
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to subscribe.</param>
|
||||||
internal void SubscribeTag(string fullTagReference)
|
internal void SubscribeTag(string fullTagReference)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
var shouldSubscribe = false;
|
||||||
|
lock (Lock)
|
||||||
{
|
{
|
||||||
if (_subscriptionRefCounts.TryGetValue(fullTagReference, out var count))
|
if (_subscriptionRefCounts.TryGetValue(fullTagReference, out var count))
|
||||||
{
|
{
|
||||||
@@ -1406,9 +1407,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
_subscriptionRefCounts[fullTagReference] = 1;
|
_subscriptionRefCounts[fullTagReference] = 1;
|
||||||
_ = _mxAccessClient.SubscribeAsync(fullTagReference, (_, _) => { });
|
shouldSubscribe = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldSubscribe)
|
||||||
|
_ = _mxAccessClient.SubscribeAsync(fullTagReference, (_, _) => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1417,14 +1421,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to unsubscribe.</param>
|
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to unsubscribe.</param>
|
||||||
internal void UnsubscribeTag(string fullTagReference)
|
internal void UnsubscribeTag(string fullTagReference)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
var shouldUnsubscribe = false;
|
||||||
|
lock (Lock)
|
||||||
{
|
{
|
||||||
if (_subscriptionRefCounts.TryGetValue(fullTagReference, out var count))
|
if (_subscriptionRefCounts.TryGetValue(fullTagReference, out var count))
|
||||||
{
|
{
|
||||||
if (count <= 1)
|
if (count <= 1)
|
||||||
{
|
{
|
||||||
_subscriptionRefCounts.Remove(fullTagReference);
|
_subscriptionRefCounts.Remove(fullTagReference);
|
||||||
_ = _mxAccessClient.UnsubscribeAsync(fullTagReference);
|
shouldUnsubscribe = true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1432,6 +1437,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldUnsubscribe)
|
||||||
|
_ = _mxAccessClient.UnsubscribeAsync(fullTagReference);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1445,25 +1453,38 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
.GroupBy(tagRef => tagRef, StringComparer.OrdinalIgnoreCase)
|
.GroupBy(tagRef => tagRef, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var tagsToSubscribe = new List<string>();
|
||||||
foreach (var kvp in transferredCounts)
|
foreach (var kvp in transferredCounts)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (Lock)
|
||||||
{
|
{
|
||||||
if (_subscriptionRefCounts.ContainsKey(kvp.Key))
|
if (_subscriptionRefCounts.ContainsKey(kvp.Key))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
_subscriptionRefCounts[kvp.Key] = kvp.Value;
|
_subscriptionRefCounts[kvp.Key] = kvp.Value;
|
||||||
|
tagsToSubscribe.Add(kvp.Key);
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = _mxAccessClient.SubscribeAsync(kvp.Key, (_, _) => { });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var tagRef in tagsToSubscribe)
|
||||||
|
_ = _mxAccessClient.SubscribeAsync(tagRef, (_, _) => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnMxAccessDataChange(string address, Vtq vtq)
|
private void OnMxAccessDataChange(string address, Vtq vtq)
|
||||||
{
|
{
|
||||||
|
if (_dispatchDisposed)
|
||||||
|
return;
|
||||||
|
|
||||||
Interlocked.Increment(ref _totalMxChangeEvents);
|
Interlocked.Increment(ref _totalMxChangeEvents);
|
||||||
_pendingDataChanges[address] = vtq;
|
_pendingDataChanges[address] = vtq;
|
||||||
_dataChangeSignal.Set();
|
try
|
||||||
|
{
|
||||||
|
_dataChangeSignal.Set();
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
// Shutdown may race with one final callback from the runtime.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -1494,103 +1515,141 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
|
|
||||||
while (_dispatchRunning)
|
while (_dispatchRunning)
|
||||||
{
|
{
|
||||||
_dataChangeSignal.WaitOne(TimeSpan.FromMilliseconds(100));
|
try
|
||||||
|
|
||||||
if (!_dispatchRunning) break;
|
|
||||||
|
|
||||||
var keys = _pendingDataChanges.Keys.ToList();
|
|
||||||
if (keys.Count == 0)
|
|
||||||
{
|
{
|
||||||
ReportDispatchMetricsIfDue();
|
_dataChangeSignal.WaitOne(TimeSpan.FromMilliseconds(100));
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare updates outside the Lock — no IO, just value conversion
|
if (!_dispatchRunning)
|
||||||
var updates = new List<(BaseDataVariableState variable, DataValue dataValue)>(keys.Count);
|
break;
|
||||||
var pendingAlarmEvents = new List<(AlarmInfo info, bool active)>();
|
|
||||||
|
|
||||||
foreach (var address in keys)
|
var keys = _pendingDataChanges.Keys.ToList();
|
||||||
{
|
if (keys.Count == 0)
|
||||||
if (_pendingDataChanges.TryRemove(address, out var vtq))
|
|
||||||
{
|
{
|
||||||
if (_tagToVariableNode.TryGetValue(address, out var variable))
|
ReportDispatchMetricsIfDue();
|
||||||
{
|
continue;
|
||||||
try
|
}
|
||||||
{
|
|
||||||
var dataValue = CreatePublishedDataValue(address, vtq);
|
|
||||||
updates.Add((variable, dataValue));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Error preparing data change for {Address}", address);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for alarm InAlarm transitions
|
// Prepare updates outside the Lock. Shared-state lookups stay inside the Lock.
|
||||||
if (_alarmInAlarmTags.TryGetValue(address, out var alarmInfo))
|
var updates = new List<(string address, BaseDataVariableState variable, DataValue dataValue)>(keys.Count);
|
||||||
{
|
var pendingAlarmEvents = new List<(string address, AlarmInfo info, bool active, ushort? severity, string? message)>();
|
||||||
var newInAlarm = vtq.Value is true || vtq.Value is 1 || (vtq.Value is int intVal && intVal != 0);
|
|
||||||
if (newInAlarm != alarmInfo.LastInAlarm)
|
|
||||||
{
|
|
||||||
alarmInfo.LastInAlarm = newInAlarm;
|
|
||||||
|
|
||||||
// Read Priority and DescAttrName via MXAccess (outside Lock, safe here)
|
foreach (var address in keys)
|
||||||
if (newInAlarm)
|
{
|
||||||
|
if (!_pendingDataChanges.TryRemove(address, out var vtq))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
AlarmInfo? alarmInfo = null;
|
||||||
|
bool newInAlarm = false;
|
||||||
|
|
||||||
|
lock (Lock)
|
||||||
|
{
|
||||||
|
if (_tagToVariableNode.TryGetValue(address, out var variable))
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
try
|
var dataValue = CreatePublishedDataValue(address, vtq);
|
||||||
{
|
updates.Add((address, variable, dataValue));
|
||||||
var pVtq = _mxAccessClient.ReadAsync(alarmInfo.PriorityTagReference).GetAwaiter().GetResult();
|
|
||||||
if (pVtq.Value is int ip) alarmInfo.CachedSeverity = (ushort)System.Math.Min(System.Math.Max(ip, 1), 1000);
|
|
||||||
else if (pVtq.Value is short sp) alarmInfo.CachedSeverity = (ushort)System.Math.Min(System.Math.Max((int)sp, 1), 1000);
|
|
||||||
}
|
|
||||||
catch { /* keep previous */ }
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var dVtq = _mxAccessClient.ReadAsync(alarmInfo.DescAttrNameTagReference).GetAwaiter().GetResult();
|
|
||||||
if (dVtq.Value is string desc && !string.IsNullOrEmpty(desc))
|
|
||||||
alarmInfo.CachedMessage = desc;
|
|
||||||
}
|
|
||||||
catch { /* keep previous */ }
|
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Error preparing data change for {Address}", address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pendingAlarmEvents.Add((alarmInfo, newInAlarm));
|
if (_alarmInAlarmTags.TryGetValue(address, out alarmInfo))
|
||||||
|
{
|
||||||
|
newInAlarm = vtq.Value is true || vtq.Value is 1 || (vtq.Value is int intVal && intVal != 0);
|
||||||
|
if (newInAlarm == alarmInfo.LastInAlarm)
|
||||||
|
alarmInfo = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply under Lock so ClearChangeMasks propagates to monitored items
|
if (alarmInfo == null)
|
||||||
if (updates.Count > 0 || pendingAlarmEvents.Count > 0)
|
continue;
|
||||||
{
|
|
||||||
lock (Lock)
|
|
||||||
{
|
|
||||||
foreach (var (variable, dataValue) in updates)
|
|
||||||
{
|
|
||||||
variable.Value = dataValue.Value;
|
|
||||||
variable.StatusCode = dataValue.StatusCode;
|
|
||||||
variable.Timestamp = dataValue.SourceTimestamp;
|
|
||||||
variable.ClearChangeMasks(SystemContext, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report alarm events
|
ushort? severity = null;
|
||||||
foreach (var (info, active) in pendingAlarmEvents)
|
string? message = null;
|
||||||
|
|
||||||
|
if (newInAlarm)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ReportAlarmEvent(info, active);
|
var pVtq = _mxAccessClient.ReadAsync(alarmInfo.PriorityTagReference).GetAwaiter().GetResult();
|
||||||
|
if (pVtq.Value is int ip)
|
||||||
|
severity = (ushort)System.Math.Min(System.Math.Max(ip, 1), 1000);
|
||||||
|
else if (pVtq.Value is short sp)
|
||||||
|
severity = (ushort)System.Math.Min(System.Math.Max((int)sp, 1), 1000);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch
|
||||||
{
|
{
|
||||||
Log.Warning(ex, "Error reporting alarm event for {Source}", info.SourceName);
|
// Keep the previously cached severity when refresh reads fail.
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dVtq = _mxAccessClient.ReadAsync(alarmInfo.DescAttrNameTagReference).GetAwaiter().GetResult();
|
||||||
|
if (dVtq.Value is string desc && !string.IsNullOrEmpty(desc))
|
||||||
|
message = desc;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep the previously cached message when refresh reads fail.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingAlarmEvents.Add((address, alarmInfo, newInAlarm, severity, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply under Lock so ClearChangeMasks propagates to monitored items.
|
||||||
|
if (updates.Count > 0 || pendingAlarmEvents.Count > 0)
|
||||||
|
{
|
||||||
|
lock (Lock)
|
||||||
|
{
|
||||||
|
foreach (var (address, variable, dataValue) in updates)
|
||||||
|
{
|
||||||
|
if (!_tagToVariableNode.TryGetValue(address, out var currentVariable) || !ReferenceEquals(currentVariable, variable))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
variable.Value = dataValue.Value;
|
||||||
|
variable.StatusCode = dataValue.StatusCode;
|
||||||
|
variable.Timestamp = dataValue.SourceTimestamp;
|
||||||
|
variable.ClearChangeMasks(SystemContext, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (address, info, active, severity, message) in pendingAlarmEvents)
|
||||||
|
{
|
||||||
|
if (!_alarmInAlarmTags.TryGetValue(address, out var currentInfo) || !ReferenceEquals(currentInfo, info))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (currentInfo.LastInAlarm == active)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
currentInfo.LastInAlarm = active;
|
||||||
|
if (severity.HasValue)
|
||||||
|
currentInfo.CachedSeverity = severity.Value;
|
||||||
|
if (!string.IsNullOrEmpty(message))
|
||||||
|
currentInfo.CachedMessage = message!;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ReportAlarmEvent(currentInfo, active);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Error reporting alarm event for {Source}", currentInfo.SourceName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Interlocked.Add(ref _totalDispatchBatchSize, updates.Count);
|
Interlocked.Add(ref _totalDispatchBatchSize, updates.Count);
|
||||||
Interlocked.Increment(ref _dispatchCycleCount);
|
Interlocked.Increment(ref _dispatchCycleCount);
|
||||||
ReportDispatchMetricsIfDue();
|
ReportDispatchMetricsIfDue();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Unhandled error in data change dispatch loop");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.Information("Data change dispatch thread stopped");
|
Log.Information("Data change dispatch thread stopped");
|
||||||
@@ -1629,6 +1688,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|||||||
{
|
{
|
||||||
if (disposing)
|
if (disposing)
|
||||||
{
|
{
|
||||||
|
_dispatchDisposed = true;
|
||||||
|
_mxAccessClient.OnTagValueChanged -= OnMxAccessDataChange;
|
||||||
StopDispatchThread();
|
StopDispatchThread();
|
||||||
_dataChangeSignal.Dispose();
|
_dataChangeSignal.Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
|
|||||||
sw.Elapsed.TotalSeconds.ShouldBeLessThan(30);
|
sw.Elapsed.TotalSeconds.ShouldBeLessThan(30);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confirms that runtime callbacks arriving after shutdown are ignored cleanly.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Stop_UnhooksNodeManagerFromMxAccessCallbacks()
|
||||||
|
{
|
||||||
|
var mxClient = new FakeMxAccessClient();
|
||||||
|
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient);
|
||||||
|
await fixture.InitializeAsync();
|
||||||
|
|
||||||
|
await fixture.DisposeAsync();
|
||||||
|
|
||||||
|
Should.NotThrow(() => mxClient.SimulateDataChange("TestMachine_001.MachineID", Vtq.Good(42)));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Confirms that the fake-backed fixture builds the seeded address space and Galaxy statistics.
|
/// Confirms that the fake-backed fixture builds the seeded address space and Galaxy statistics.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -57,6 +58,25 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
|
|||||||
_client.ActiveSubscriptionCount.ShouldBe(1);
|
_client.ActiveSubscriptionCount.ShouldBe(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confirms that subscribing to the same address twice reuses the existing runtime item.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Subscribe_SameAddressTwice_ReusesExistingRuntimeItem()
|
||||||
|
{
|
||||||
|
await _client.ConnectAsync();
|
||||||
|
|
||||||
|
await _client.SubscribeAsync("TestTag.Attr", (_, _) => { });
|
||||||
|
await _client.SubscribeAsync("TestTag.Attr", (_, _) => { });
|
||||||
|
|
||||||
|
_client.ActiveSubscriptionCount.ShouldBe(1);
|
||||||
|
_proxy.Items.Values.Count(v => v == "TestTag.Attr").ShouldBe(1);
|
||||||
|
|
||||||
|
await _client.UnsubscribeAsync("TestTag.Attr");
|
||||||
|
|
||||||
|
_proxy.Items.Values.ShouldNotContain("TestTag.Attr");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Confirms that unsubscribing clears the active subscription count after a tag was previously monitored.
|
/// Confirms that unsubscribing clears the active subscription count after a tag was previously monitored.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user