Compare commits
127 Commits
phase-6-3-
...
phase-2-cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33b87a3aa4 | ||
| 2391de7f79 | |||
|
|
f9bc301c33 | ||
|
|
12d748c4f3 | ||
| e9b1d107ab | |||
|
|
5506b43ddc | ||
|
|
71339307fa | ||
|
|
985b7aba26 | ||
|
|
48970af416 | ||
|
|
f217636467 | ||
| 5f26fff4f1 | |||
|
|
5c0d3154c1 | ||
| 74067e7d7e | |||
|
|
ef53553e9d | ||
| d1e50db304 | |||
|
|
df0d7c2d84 | ||
| 16f4b4acad | |||
|
|
ac63c2cfb2 | ||
| d93dc73978 | |||
|
|
ecc2389ca8 | ||
| 852c710013 | |||
|
|
8ce5791f49 | ||
| 05ddea307b | |||
|
|
32dff7f1d6 | ||
| 42649ca7b0 | |||
|
|
1f3343e61f | ||
| 251f567b98 | |||
|
|
404bfbe7e4 | ||
| 006af636a0 | |||
|
|
c0751fdda5 | ||
| 80e080ecec | |||
|
|
5ee510dc1a | ||
| 543665dedd | |||
|
|
c8a38bc57b | ||
| cecb84fa5d | |||
|
|
13d5a7968b | ||
| d1686ed82d | |||
|
|
ac69a1c39d | ||
| 30714831fa | |||
|
|
44d4448b37 | ||
| 572f8887e4 | |||
|
|
2acea08ced | ||
| 49f6c9484e | |||
|
|
d06cc01a48 | ||
| 5536e96b46 | |||
|
|
ece530d133 | ||
| b55cef5f8b | |||
|
|
088c4817fe | ||
| 91e6153b5d | |||
|
|
00a428c444 | ||
| 07fd105ffc | |||
|
|
8c309aebf3 | ||
| d1ca0817e9 | |||
|
|
c95228391d | ||
| 9ca80fd450 | |||
|
|
1d6015bc87 | ||
| 5cfb0fc6d0 | |||
|
|
a2c7fda5f5 | ||
| c13fe8f587 | |||
|
|
285799a954 | ||
| 9da578d5a5 | |||
|
|
6c5b202910 | ||
| a0112ddb43 | |||
|
|
aeb28cc8e7 | ||
| 2d5aaf1eda | |||
|
|
28e3470300 | ||
| bffac4db65 | |||
|
|
cd2c0bcadd | ||
| 7fdf4e5618 | |||
|
|
400fc6242c | ||
| 4438fdd7b1 | |||
|
|
b2424a0616 | ||
| 59c99190c6 | |||
|
|
fc575e8dae | ||
| 70f5f2cad1 | |||
|
|
60b8d6f2d0 | ||
| 30f971599e | |||
|
|
ac14ba9664 | ||
| 5978ea002d | |||
|
|
33780eb64c | ||
| 521bcb2f68 | |||
|
|
b06a1ba607 | ||
| dd1389a8e7 | |||
|
|
447086892e | ||
| cee52a9134 | |||
|
|
257f4fd3f5 | ||
| be2379107d | |||
|
|
cc35c77d64 | ||
| 59b59b8ccd | |||
|
|
3e0452e8a4 | ||
| bff6651b4b | |||
|
|
4ab587707f | ||
| 2172d49d2e | |||
|
|
ae8f226e45 | ||
| e032045247 | |||
|
|
ad131932d3 | ||
| 98b69ff4f9 | |||
|
|
016122841b | ||
| 244a36e03e | |||
|
|
4de94fab0d | ||
| fdd0bf52c3 | |||
|
|
7b50118b68 | ||
| eac457fa7c | |||
|
|
c1cab33e38 | ||
| 0c903ff4e0 | |||
|
|
c4a92f424a | ||
| 510e488ea4 | |||
| 8994e73a0b | |||
|
|
e71f44603c | ||
|
|
c4824bea12 | ||
| e588c4f980 | |||
|
|
84fe88fadb | ||
| 59f793f87c | |||
| 37ba9e8d14 | |||
|
|
a8401ab8fd | ||
|
|
19a0bfcc43 | ||
| fc7e18c7f5 | |||
|
|
ba42967943 | ||
| b912969805 | |||
|
|
f8d5b0fdbb | ||
| cc069509cd | |||
|
|
3b2d0474a7 | ||
| e1d38ecc66 | |||
|
|
99cf1197c5 | ||
| ad39f866e5 | |||
|
|
560a961cca | ||
| 4901b78e9a |
@@ -87,13 +87,14 @@ The server supports non-transparent warm/hot redundancy via the `Redundancy` sec
|
||||
|
||||
## LDAP Authentication
|
||||
|
||||
The server uses LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapAuthenticationProvider` implements both `IUserAuthenticationProvider` and `IRoleProvider`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference.
|
||||
The server uses LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapUserAuthenticator` (`src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs`) implements `IUserAuthenticator`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference.
|
||||
|
||||
## Library Preferences
|
||||
|
||||
- **Logging**: Serilog with rolling daily file sink
|
||||
- **Unit tests**: xUnit + Shouldly for assertions
|
||||
- **Service hosting**: TopShelf (Windows service install/uninstall/run as console)
|
||||
- **Service hosting (Server, Admin)**: .NET generic host with `AddWindowsService` (decision #30 — replaced TopShelf in v2; see `src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs`)
|
||||
- **Service hosting (Galaxy.Host)**: plain console app wrapped by NSSM (`.NET Framework 4.8 x86` — required by MXAccess COM bitness)
|
||||
- **OPC UA**: OPC Foundation UA .NET Standard stack (https://github.com/opcfoundation/ua-.netstandard) — NuGet: `OPCFoundation.NetStandard.Opc.Ua.Server`
|
||||
|
||||
## OPC UA .NET Standard Documentation
|
||||
|
||||
@@ -10,10 +10,15 @@
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj"/>
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj"/>
|
||||
@@ -29,9 +34,15 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/ZB.MOM.WW.OtOpcUa.Analyzers.Tests.csproj"/>
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
20
ci/ab-server.lock.json
Normal file
20
ci/ab-server.lock.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"_comment": "Pinned libplctag release used by tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture. ab_server.exe ships inside the *_tools.zip asset on every GitHub release. See docs/v2/test-data-sources.md §2.CI for the GitHub Actions step that consumes this file.",
|
||||
"repo": "libplctag/libplctag",
|
||||
"tag": "v2.6.16",
|
||||
"published": "2026-03-29",
|
||||
"assets": {
|
||||
"windows-x64": {
|
||||
"file": "libplctag_2.6.16_windows_x64_tools.zip",
|
||||
"sha256": "9b78a3dee73d9cd28ca348c090f453dbe3ad9d07ad6bf42865a9dc3a79bc2232"
|
||||
},
|
||||
"windows-x86": {
|
||||
"file": "libplctag_2.6.16_windows_x86_tools.zip",
|
||||
"sha256": "fdfefd58b266c5da9a1ded1a430985e609289c9e67be2544da7513b668761edf"
|
||||
},
|
||||
"windows-arm64": {
|
||||
"file": "libplctag_2.6.16_windows_arm64_tools.zip",
|
||||
"sha256": "d747728e4c4958bb63b4ac23e1c820c4452e4778dfd7d58f8a0aecd5402d4944"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +1,72 @@
|
||||
# Address Space
|
||||
|
||||
The address space maps the Galaxy object hierarchy and attribute definitions into an OPC UA browse tree. `LmxNodeManager` builds the tree from data queried by `GalaxyRepositoryService`, while `AddressSpaceBuilder` provides a testable in-memory model of the same structure.
|
||||
Each driver's browsable subtree is built by streaming nodes from the driver's `ITagDiscovery.DiscoverAsync` implementation into an `IAddressSpaceBuilder`. `GenericDriverNodeManager` (`src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`) owns the shared orchestration; `DriverNodeManager` (`src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs`) implements `IAddressSpaceBuilder` against the OPC Foundation stack's `CustomNodeManager2`. The same code path serves Galaxy object hierarchies, Modbus PLC registers, AB CIP tags, TwinCAT symbols, FOCAS CNC parameters, and OPC UA Client aggregations — Galaxy is one driver of seven, not the driver.
|
||||
|
||||
## Root ZB Folder
|
||||
## Driver root folder
|
||||
|
||||
Every address space starts with a single root folder node named `ZB` (NodeId `ns=1;s=ZB`). This folder is added under the standard OPC UA `Objects` folder via an `Organizes` reference. The reverse reference is registered through `MasterNodeManager.AddReferences` because `BuildAddressSpace` runs after `CreateAddressSpace` has already consumed the external references dictionary.
|
||||
Every driver's subtree starts with a root `FolderState` under the standard OPC UA `Objects` folder, wired with an `Organizes` reference. `DriverNodeManager.CreateAddressSpace` creates this folder with `NodeId = ns;s={DriverInstanceId}`, `BrowseName = {DriverInstanceId}`, and `EventNotifier = SubscribeToEvents | HistoryRead` so alarm and history-event subscriptions can target the root. The namespace URI is `urn:OtOpcUa:{DriverInstanceId}`.
|
||||
|
||||
The root folder has `EventNotifier = SubscribeToEvents` enabled so alarm events propagate up to clients subscribed at the root level.
|
||||
## IAddressSpaceBuilder surface
|
||||
|
||||
## Area Folders vs Object Nodes
|
||||
`IAddressSpaceBuilder` (`src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs`) offers three calls:
|
||||
|
||||
Galaxy objects fall into two categories based on `template_definition.category_id`:
|
||||
- `Folder(browseName, displayName)` — creates a child `FolderState` and returns a child builder scoped to it.
|
||||
- `Variable(browseName, displayName, DriverAttributeInfo attributeInfo)` — creates a `BaseDataVariableState` and returns an `IVariableHandle` the driver keeps for alarm wiring.
|
||||
- `AddProperty(browseName, DriverDataType, value)` — attaches a `PropertyState` for static metadata (e.g. equipment identification fields).
|
||||
|
||||
- **Areas** (`category_id = 13`) become `FolderState` nodes with `FolderType` type definition and `Organizes` references. They represent logical groupings in the Galaxy hierarchy (e.g., production lines, cells).
|
||||
- **Non-area objects** (AppEngine, Platform, UserDefined, etc.) become `BaseObjectState` nodes with `BaseObjectType` type definition and `HasComponent` references. These represent runtime automation objects that carry attributes.
|
||||
Drivers drive ordering. Typical pattern: root → folder per equipment → variables per tag. `GenericDriverNodeManager` calls `DiscoverAsync` once on startup and once per rediscovery cycle.
|
||||
|
||||
Both node types use `contained_name` as the browse name. When `contained_name` is null or empty, `tag_name` is used as a fallback.
|
||||
## DriverAttributeInfo → OPC UA variable
|
||||
|
||||
## Variable Nodes for Attributes
|
||||
Each variable carries a `DriverAttributeInfo` (`src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs`):
|
||||
|
||||
Each Galaxy attribute becomes a `BaseDataVariableState` node under its parent object. The variable is configured with:
|
||||
| Field | OPC UA target |
|
||||
|---|---|
|
||||
| `FullName` | `NodeId.Identifier` — used as the driver-side lookup key for Read/Write/Subscribe |
|
||||
| `DriverDataType` | mapped to a built-in `DataTypeIds.*` NodeId via `DriverNodeManager.MapDataType` |
|
||||
| `IsArray` | `ValueRank = OneDimension` when true, `Scalar` otherwise |
|
||||
| `ArrayDim` | declared array length, carried through as metadata |
|
||||
| `SecurityClass` | stored in `_securityByFullRef` for `WriteAuthzPolicy` gating on write |
|
||||
| `IsHistorized` | flips `AccessLevel.HistoryRead` + `Historizing = true` |
|
||||
| `IsAlarm` | drives the `MarkAsAlarmCondition` pass (see below) |
|
||||
| `WriteIdempotent` | stored in `_writeIdempotentByFullRef`; fed to `CapabilityInvoker.ExecuteWriteAsync` |
|
||||
|
||||
- **DataType** -- Mapped from `mx_data_type` via `MxDataTypeMapper` (see [DataTypeMapping.md](DataTypeMapping.md))
|
||||
- **ValueRank** -- `OneDimension` (1) for arrays, `Scalar` (-1) for scalars
|
||||
- **ArrayDimensions** -- Set to `[array_dimension]` when the attribute is an array
|
||||
- **AccessLevel** -- `CurrentReadOrWrite` or `CurrentRead` based on security classification, with `HistoryRead` added for historized attributes
|
||||
- **Historizing** -- Set to `true` for attributes with a `HistoryExtension` primitive
|
||||
- **Initial value** -- `null` with `StatusCode = BadWaitingForInitialData` until the first MXAccess callback delivers a live value
|
||||
The initial value stays `null` with `StatusCode = BadWaitingForInitialData` until the first Read or `ISubscribable.OnDataChange` push lands.
|
||||
|
||||
## Primitive Grouping
|
||||
## CapturingBuilder + alarm sink registration
|
||||
|
||||
Galaxy objects can have primitive components (e.g., alarm extensions, history extensions) that attach sub-attributes to a parent attribute. The address space handles this with a two-pass approach:
|
||||
`GenericDriverNodeManager.BuildAddressSpaceAsync` wraps the supplied builder in a `CapturingBuilder` before calling `DiscoverAsync`. The wrapper observes every `Variable()` call: when a returned `IVariableHandle.MarkAsAlarmCondition(AlarmConditionInfo)` fires, the sink is registered in the manager's `_alarmSinks` dictionary keyed by the variable's `FullReference`. Subsequent `IAlarmSource.OnAlarmEvent` pushes are routed to the matching sink by `SourceNodeId`. This keeps the alarm-wiring protocol declarative — drivers just flag `DriverAttributeInfo.IsAlarm = true` and the materialization of the OPC UA `AlarmConditionState` node is handled by the server layer. See `docs/AlarmTracking.md`.
|
||||
|
||||
### First pass: direct attributes
|
||||
## NodeId scheme
|
||||
|
||||
Attributes with an empty `PrimitiveName` are created as direct variable children of the object node. If a direct attribute shares its name with a primitive group, the variable node reference is saved for the second pass.
|
||||
|
||||
### Second pass: primitive child attributes
|
||||
|
||||
Attributes with a non-empty `PrimitiveName` are grouped by that name. For each group:
|
||||
|
||||
1. If a direct attribute variable with the same name already exists, the primitive's child attributes are added as `HasComponent` children of that variable node. This merges alarm/history sub-attributes (e.g., `InAlarm`, `Priority`) under the parent variable they describe.
|
||||
2. If no matching direct attribute exists, a new `BaseObjectState` node is created with NodeId `ns=1;s={TagName}.{PrimitiveName}`, and the primitive's attributes are added under it.
|
||||
|
||||
This structure means that browsing `TestMachine_001/SomeAlarmAttr` reveals both the process value and its alarm sub-attributes (`InAlarm`, `Priority`, `DescAttrName`) as children.
|
||||
|
||||
## NodeId Scheme
|
||||
|
||||
All node identifiers use string-based NodeIds in namespace index 1 (`ns=1`):
|
||||
All nodes live in the driver's namespace (not a shared `ns=1`). Browse paths are driver-defined:
|
||||
|
||||
| Node type | NodeId format | Example |
|
||||
|-----------|---------------|---------|
|
||||
| Root folder | `ns=1;s=ZB` | `ns=1;s=ZB` |
|
||||
| Area folder | `ns=1;s={tag_name}` | `ns=1;s=Area_001` |
|
||||
| Object node | `ns=1;s={tag_name}` | `ns=1;s=TestMachine_001` |
|
||||
| Scalar variable | `ns=1;s={tag_name}.{attr}` | `ns=1;s=TestMachine_001.MachineID` |
|
||||
| Array variable | `ns=1;s={tag_name}.{attr}` | `ns=1;s=MESReceiver_001.MoveInPartNumbers` |
|
||||
| Primitive sub-object | `ns=1;s={tag_name}.{prim}` | `ns=1;s=TestMachine_001.AlarmPrim` |
|
||||
|---|---|---|
|
||||
| Driver root | `ns;s={DriverInstanceId}` | `urn:OtOpcUa:galaxy-01;s=galaxy-01` |
|
||||
| Folder | `ns;s={parent}/{browseName}` | `ns;s=galaxy-01/Area_001` |
|
||||
| Variable | `ns;s={DriverAttributeInfo.FullName}` | `ns;s=DelmiaReceiver_001.DownloadPath` |
|
||||
| Alarm condition | `ns;s={FullReference}.Condition` | `ns;s=DelmiaReceiver_001.Temperature.Condition` |
|
||||
|
||||
For array attributes, the `[]` suffix present in `full_tag_reference` is stripped from the NodeId. The `full_tag_reference` (with `[]`) is kept internally for MXAccess subscription addressing. This means `MESReceiver_001.MoveInPartNumbers[]` in the Galaxy maps to NodeId `ns=1;s=MESReceiver_001.MoveInPartNumbers`.
|
||||
For Galaxy the `FullName` stays in the legacy `tag_name.AttributeName` format; Modbus uses `unit:register:type`; AB CIP uses the native `program:tag.member` path; etc. — the shape is the driver's choice.
|
||||
|
||||
## Topological Sort
|
||||
## Per-driver hierarchy examples
|
||||
|
||||
The hierarchy query returns objects ordered by `parent_gobject_id, tag_name`, but this does not guarantee that a parent appears before all of its children in all cases. `LmxNodeManager.TopologicalSort` performs a depth-first traversal to produce a list where every parent is guaranteed to precede its children. This allows the build loop to look up parent nodes from `_nodeMap` without forward references.
|
||||
- **Galaxy Proxy**: walks the DB-snapshot hierarchy (`GalaxyProxyDriver.DiscoverAsync`), streams Area objects as folders and non-area objects as variable-bearing folders, marks `IsAlarm = true` on attributes that have an `AlarmExtension` primitive. The v1 two-pass primitive-grouping logic is retained inside the Galaxy driver.
|
||||
- **Modbus**: streams one folder per device, one variable per register range from `ModbusDriverOptions`. No alarm surface.
|
||||
- **AB CIP**: uses `AbCipTemplateCache` to enumerate user-defined types, streams a folder per program with variables keyed on the native tag path.
|
||||
- **OPC UA Client**: re-exposes a remote server's address space — browses the upstream and relays nodes through the builder.
|
||||
|
||||
## Platform Scope Filtering
|
||||
See `docs/v2/driver-specs.md` for the per-driver discovery contracts.
|
||||
|
||||
When `GalaxyRepository.Scope` is set to `LocalPlatform`, the hierarchy and attributes passed to `BuildAddressSpace` are pre-filtered by `PlatformScopeFilter` inside `GalaxyRepositoryService`. The node manager receives only the local platform's objects and their ancestor areas, so the resulting browse tree is a subset of the full Galaxy. The filtering is transparent to `LmxNodeManager` — it builds nodes from whatever data it receives.
|
||||
## Rediscovery
|
||||
|
||||
Clients browsing a `LocalPlatform`-scoped server will see only the areas and objects hosted by that platform. Areas that exist in the Galaxy but contain no local descendants are excluded. See [Galaxy Repository — Platform Scope Filter](GalaxyRepository.md#platform-scope-filter) for the filtering algorithm and configuration.
|
||||
|
||||
## Incremental Sync
|
||||
|
||||
On address space rebuild (triggered by a Galaxy deploy change), `SyncAddressSpace` uses `AddressSpaceDiff` to identify which `gobject_id` values have changed between the old and new snapshots. Only the affected subtrees are torn down and rebuilt, preserving unchanged nodes and their active subscriptions. Affected subscriptions are snapshot before teardown and replayed after rebuild.
|
||||
|
||||
If no previous state is cached (first build), the full `BuildAddressSpace` path runs instead.
|
||||
Drivers that implement `IRediscoverable` fire `OnRediscoveryNeeded` when their backend signals a change (Galaxy: `time_of_last_deploy` advance; TwinCAT: symbol-version-changed; OPC UA Client: server namespace change). Core re-runs `DiscoverAsync` and diffs — see `docs/IncrementalSync.md`. Static drivers (Modbus, S7) don't implement `IRediscoverable`; their address space only changes when a new generation is published from the Config DB.
|
||||
|
||||
## Key source files
|
||||
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxNodeManager.cs` -- Node manager with `BuildAddressSpace`, `SyncAddressSpace`, and `TopologicalSort`
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/AddressSpaceBuilder.cs` -- Testable in-memory model builder
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — orchestration + `CapturingBuilder`
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs` — OPC UA materialization (`IAddressSpaceBuilder` impl + `NestedBuilder`)
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs` — builder contract
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs` — driver discovery capability
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs` — per-attribute descriptor
|
||||
|
||||
@@ -1,234 +1,76 @@
|
||||
# Alarm Tracking
|
||||
|
||||
`LmxNodeManager` generates OPC UA alarm conditions from Galaxy attributes marked as alarms. The system detects alarm-capable attributes during address space construction, creates `AlarmConditionState` nodes, auto-subscribes to the runtime alarm tags via MXAccess, and reports state transitions as OPC UA events.
|
||||
Alarm surfacing is an optional driver capability exposed via `IAlarmSource` (`src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs`). Drivers whose backends have an alarm concept implement it — today: Galaxy (MXAccess alarms), FOCAS (CNC alarms), OPC UA Client (A&C events from the upstream server). Modbus / S7 / AB CIP / AB Legacy / TwinCAT do not implement the interface and the feature is simply absent from their subtrees.
|
||||
|
||||
## AlarmInfo Structure
|
||||
|
||||
Each tracked alarm is represented by an `AlarmInfo` instance stored in the `_alarmInAlarmTags` dictionary, keyed by the `InAlarm` tag reference:
|
||||
## IAlarmSource surface
|
||||
|
||||
```csharp
|
||||
private sealed class AlarmInfo
|
||||
{
|
||||
public string SourceTagReference { get; set; } // e.g., "Tag_001.Temperature"
|
||||
public NodeId SourceNodeId { get; set; }
|
||||
public string SourceName { get; set; } // attribute name for event messages
|
||||
public bool LastInAlarm { get; set; } // tracks previous state for edge detection
|
||||
public AlarmConditionState? ConditionNode { get; set; }
|
||||
public string PriorityTagReference { get; set; } // e.g., "Tag_001.Temperature.Priority"
|
||||
public string DescAttrNameTagReference { get; set; } // e.g., "Tag_001.Temperature.DescAttrName"
|
||||
public ushort CachedSeverity { get; set; }
|
||||
public string CachedMessage { get; set; }
|
||||
}
|
||||
Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken);
|
||||
Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken);
|
||||
Task AcknowledgeAsync(IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
||||
CancellationToken cancellationToken);
|
||||
event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
```
|
||||
|
||||
`LastInAlarm` enables edge detection so only actual transitions (inactive-to-active or active-to-inactive) generate events, not repeated identical values.
|
||||
The driver fires `OnAlarmEvent` for every transition (`Active`, `Acknowledged`, `Inactive`) with an `AlarmEventArgs` carrying the source node id, condition id, alarm type, message, severity (`AlarmSeverity` enum), and source timestamp.
|
||||
|
||||
## Alarm Detection via is_alarm Flag
|
||||
## AlarmSurfaceInvoker
|
||||
|
||||
During `BuildAddressSpace` (and `BuildSubtree` for incremental sync), the node manager scans each non-area Galaxy object for attributes where `IsAlarm == true` and `PrimitiveName` is empty (direct attributes only, not primitive children):
|
||||
`AlarmSurfaceInvoker` (`src/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs`) wraps the three mutating surfaces through `CapabilityInvoker`:
|
||||
|
||||
```csharp
|
||||
var alarmAttrs = objAttrs.Where(a => a.IsAlarm && string.IsNullOrEmpty(a.PrimitiveName)).ToList();
|
||||
```
|
||||
- `SubscribeAlarmsAsync` / `UnsubscribeAlarmsAsync` run through the `DriverCapability.AlarmSubscribe` pipeline — retries apply under the tier configuration.
|
||||
- `AcknowledgeAsync` runs through `DriverCapability.AlarmAcknowledge` which does NOT retry per decision #143. A timed-out ack may have already registered at the plant floor; replay would silently double-acknowledge.
|
||||
|
||||
The `IsAlarm` flag originates from the `AlarmExtension` primitive in the Galaxy repository database. When a Galaxy attribute has an associated `AlarmExtension` primitive, the SQL query sets `is_alarm = 1` on the corresponding `GalaxyAttributeInfo`.
|
||||
Multi-host fan-out: when the driver implements `IPerCallHostResolver`, each source node id is resolved individually and batches are grouped by host so a dead PLC inside a multi-device driver doesn't poison sibling breakers. Single-host drivers fall back to `IDriver.DriverInstanceId` as the pipeline-key host.
|
||||
|
||||
For each alarm attribute, the code verifies that a corresponding `InAlarm` sub-attribute variable node exists in `_tagToVariableNode` (constructed from `FullTagReference + ".InAlarm"`). If the variable node is missing, the alarm is skipped -- this prevents creating orphaned alarm conditions for attributes whose extension primitives were not published.
|
||||
## Condition-node creation via CapturingBuilder
|
||||
|
||||
## Template-Based Alarm Object Filter
|
||||
Alarm-condition nodes are materialized at address-space build time. During `GenericDriverNodeManager.BuildAddressSpaceAsync` the builder is wrapped in a `CapturingBuilder` that observes every `Variable()` call. When a driver calls `IVariableHandle.MarkAsAlarmCondition(AlarmConditionInfo)` on a returned handle, the server-side `DriverNodeManager.VariableHandle` creates a sibling `AlarmConditionState` node and returns an `IAlarmConditionSink`. The wrapper stores the sink in `_alarmSinks` keyed by the variable's full reference, then `GenericDriverNodeManager` registers a forwarder on `IAlarmSource.OnAlarmEvent` that routes each push to the matching sink by `SourceNodeId`. Unknown source ids are dropped silently — they may belong to another driver.
|
||||
|
||||
When large galaxies contain more alarm-bearing objects than clients need, `OpcUa.AlarmFilter.ObjectFilters` restricts alarm condition creation to a subset of objects selected by **template name pattern**. The filter is applied at both alarm creation sites -- the full build in `BuildAddressSpace` and the subtree rebuild path triggered by Galaxy redeployment -- so the included set is recomputed on every rebuild against the fresh hierarchy.
|
||||
The `AlarmConditionState` layout matches OPC UA Part 9:
|
||||
|
||||
### Matching rules
|
||||
- `SourceNode` → the originating variable
|
||||
- `SourceName` / `ConditionName` → from `AlarmConditionInfo.SourceName`
|
||||
- Initial state: enabled, inactive, acknowledged, severity per `InitialSeverity`, retain false
|
||||
- `HasCondition` references wire the source variable ↔ the condition node bidirectionally
|
||||
|
||||
- `*` is the only wildcard (glob-style, zero or more characters). All other regex metacharacters are escaped and matched literally.
|
||||
- Matching is case-insensitive.
|
||||
- The leading `$` used by Galaxy template `tag_name` values is normalized away on both the stored chain entry and the operator pattern, so `TestMachine*` matches the stored `$TestMachine`.
|
||||
- Each configured entry may itself be comma-separated for operator convenience (`"TestMachine*, Pump_*"`).
|
||||
- An empty list disables the filter and restores the prior behavior: every alarm-bearing object is tracked when `AlarmTrackingEnabled=true`.
|
||||
Drivers flag alarm-bearing variables at discovery time via `DriverAttributeInfo.IsAlarm = true`. The Galaxy driver, for example, sets this on attributes that have an `AlarmExtension` primitive in the Galaxy repository DB; FOCAS sets it on the CNC alarm register.
|
||||
|
||||
### What gets included
|
||||
## State transitions
|
||||
|
||||
Every Galaxy object whose **template derivation chain** contains any template matching any pattern is included. The chain walks `gobject.derived_from_gobject_id` from the instance through its immediate template and each ancestor template, up to `$Object`. An instance of `TestCoolMachine` whose chain is `$TestCoolMachine -> $TestMachine -> $UserDefined` matches the pattern `TestMachine` via the ancestor hit.
|
||||
`ConditionSink.OnTransition` runs under the node manager's `Lock` and maps the `AlarmEventArgs.AlarmType` string to Part 9 state:
|
||||
|
||||
Inclusion propagates down the **containment hierarchy**: if an object matches, all of its descendants are included as well, regardless of their own template chains. This lets operators target a parent and pick up all its alarm-bearing children with one pattern.
|
||||
| AlarmType | Action |
|
||||
|---|---|
|
||||
| `Active` | `SetActiveState(true)`, `SetAcknowledgedState(false)`, `Retain = true` |
|
||||
| `Acknowledged` | `SetAcknowledgedState(true)` |
|
||||
| `Inactive` | `SetActiveState(false)`; `Retain = false` once both inactive and acknowledged |
|
||||
|
||||
Each object is evaluated exactly once. Overlapping matches (multiple patterns hit, or both an ancestor and descendant match independently) never produce duplicate alarm condition subscriptions -- the filter operates on object identity via a `HashSet<int>` of included `GobjectId` values.
|
||||
Severity is remapped: `AlarmSeverity.Low/Medium/High/Critical` → OPC UA numeric 250 / 500 / 700 / 900. `Message.Value` is set from `AlarmEventArgs.Message` on every transition. `ClearChangeMasks(true)` and `ReportEvent(condition)` fire the OPC UA event notification for clients subscribed to any ancestor notifier.
|
||||
|
||||
### Resolution algorithm
|
||||
## Acknowledge dispatch
|
||||
|
||||
`AlarmObjectFilter.ResolveIncludedObjects(hierarchy)` runs once per build:
|
||||
Alarm acknowledgement initiated by an OPC UA client flows:
|
||||
|
||||
1. Compile each pattern into a regex with `IgnoreCase | CultureInvariant | Compiled`.
|
||||
2. Build a `parent -> children` map from the hierarchy. Orphans (parent id not in the hierarchy) are treated as roots.
|
||||
3. BFS from each root with a `(nodeId, parentIncluded)` queue and a `visited` set for cycle defense.
|
||||
4. At each node: if the parent was included OR any chain entry matches any pattern, add the node and mark its subtree as included.
|
||||
5. Return the `HashSet<int>` of included object IDs. When no patterns are configured the filter is disabled and the method returns `null`, which the alarm loop treats as "no filtering".
|
||||
1. The SDK invokes the `AlarmConditionState.OnAcknowledge` method delegate.
|
||||
2. The handler checks the session's roles for `AlarmAck` — drivers never see a request the session wasn't entitled to make.
|
||||
3. `AlarmSurfaceInvoker.AcknowledgeAsync` is called with the source / condition / comment tuple. The invoker groups by host and runs each batch through the no-retry `AlarmAcknowledge` pipeline.
|
||||
|
||||
After each resolution, `UnmatchedPatterns` exposes any raw pattern that matched zero objects so the startup log can warn about operator typos without failing startup.
|
||||
Drivers return normally for success or throw to signal the ack failed at the backend.
|
||||
|
||||
### How the alarm loop applies the filter
|
||||
## EventNotifier propagation
|
||||
|
||||
```csharp
|
||||
// LmxNodeManager.BuildAddressSpace (and the subtree rebuild path)
|
||||
if (_alarmTrackingEnabled)
|
||||
{
|
||||
var includedIds = ResolveAlarmFilterIncludedIds(sorted); // null if no filter
|
||||
foreach (var obj in sorted)
|
||||
{
|
||||
if (obj.IsArea) continue;
|
||||
if (includedIds != null && !includedIds.Contains(obj.GobjectId)) continue;
|
||||
// ... existing alarm-attribute collection + AlarmConditionState creation
|
||||
}
|
||||
}
|
||||
```
|
||||
Drivers that want hierarchical alarm subscriptions propagate `EventNotifier.SubscribeToEvents` up the containment chain during discovery — the Galaxy driver flips the flag on every ancestor of an alarm-bearing object up to the driver root, mirroring v1 behavior. Clients subscribed at the driver root, a mid-level folder, or the `Objects/` root see alarm events from every descendant with an `AlarmConditionState` sibling. The driver-root `FolderState` is created in `DriverNodeManager.CreateAddressSpace` with `EventNotifier = SubscribeToEvents | HistoryRead` so alarm event subscriptions and alarm history both have a single natural target.
|
||||
|
||||
`ResolveAlarmFilterIncludedIds` also emits a one-line summary (`Alarm filter: X of Y objects included (Z pattern(s))`) and per-pattern warnings for patterns that matched nothing. The included count is published to the dashboard via `AlarmFilterIncludedObjectCount`.
|
||||
## ConditionRefresh
|
||||
|
||||
### Runtime telemetry
|
||||
The OPC UA `ConditionRefresh` service queues the current state of every retained condition back to the requesting monitored items. `DriverNodeManager` iterates the node manager's `AlarmConditionState` collection and queues each condition whose `Retain.Value == true` — matching the Part 9 requirement.
|
||||
|
||||
`LmxNodeManager` exposes three read-only properties populated by the filter:
|
||||
## Key source files
|
||||
|
||||
- `AlarmFilterEnabled` -- true when patterns are configured.
|
||||
- `AlarmFilterPatternCount` -- number of compiled patterns.
|
||||
- `AlarmFilterIncludedObjectCount` -- number of objects in the most recent included set.
|
||||
|
||||
`StatusReportService` reads these into `AlarmStatusInfo.FilterEnabled`, `FilterPatternCount`, and `FilterIncludedObjectCount`. The Alarms panel on the dashboard renders `Filter: N pattern(s), M object(s) included` only when the filter is enabled. See [Status Dashboard](StatusDashboard.md#alarms).
|
||||
|
||||
### Validator warning
|
||||
|
||||
`ConfigurationValidator.ValidateAndLog()` logs the effective filter at startup and emits a `Warning` if `AlarmFilter.ObjectFilters` is non-empty while `AlarmTrackingEnabled` is `false`, because the filter would have no effect.
|
||||
|
||||
## AlarmConditionState Creation
|
||||
|
||||
Each detected alarm attribute produces an `AlarmConditionState` node:
|
||||
|
||||
```csharp
|
||||
var condition = new AlarmConditionState(sourceVariable);
|
||||
condition.Create(SystemContext, conditionNodeId,
|
||||
new QualifiedName(alarmAttr.AttributeName + "Alarm", NamespaceIndex),
|
||||
new LocalizedText("en", alarmAttr.AttributeName + " Alarm"),
|
||||
true);
|
||||
```
|
||||
|
||||
Key configuration on the condition node:
|
||||
|
||||
- **SourceNode** -- Set to the OPC UA NodeId of the source variable, linking the condition to the attribute that triggered it.
|
||||
- **SourceName / ConditionName** -- Set to the Galaxy attribute name for identification in event notifications.
|
||||
- **AutoReportStateChanges** -- Set to `true` so the OPC UA framework automatically generates event notifications when condition properties change.
|
||||
- **Initial state** -- Enabled, inactive, acknowledged, severity Medium, retain false.
|
||||
- **HasCondition references** -- Bidirectional references are added between the source variable and the condition node.
|
||||
|
||||
The condition's `OnReportEvent` callback forwards events to `Server.ReportEvent` so they reach clients subscribed at the server level.
|
||||
|
||||
### Condition Methods
|
||||
|
||||
Each alarm condition supports the following OPC UA Part 9 methods:
|
||||
|
||||
- **Acknowledge** (`OnAcknowledge`) -- Writes the acknowledgment message to the Galaxy `AckMsg` tag. Requires the `AlarmAck` role.
|
||||
- **Confirm** (`OnConfirm`) -- Confirms a previously acknowledged alarm. The SDK manages the `ConfirmedState` transition.
|
||||
- **AddComment** (`OnAddComment`) -- Attaches an operator comment to the condition for audit trail purposes.
|
||||
- **Enable / Disable** (`OnEnableDisable`) -- Activates or deactivates alarm monitoring for the specific condition. The SDK manages the `EnabledState` transition.
|
||||
- **Shelve** (`OnShelve`) -- Supports `TimedShelve`, `OneShotShelve`, and `Unshelve` operations. The SDK manages the `ShelvedStateMachineType` state transitions including automatic timed unshelve.
|
||||
- **TimedUnshelve** (`OnTimedUnshelve`) -- Automatically called by the SDK when a timed shelve period expires.
|
||||
|
||||
### Event Fields
|
||||
|
||||
Alarm events include the following fields:
|
||||
|
||||
- `EventId` -- Unique GUID for each event, used as reference for Acknowledge/Confirm
|
||||
- `ActiveState`, `AckedState`, `ConfirmedState` -- State transitions
|
||||
- `Message` -- Alarm message from Galaxy `DescAttrName` or default text
|
||||
- `Severity` -- Galaxy Priority clamped to OPC UA range 1-1000
|
||||
- `Retain` -- True while alarm is active or unacknowledged
|
||||
- `LocalTime` -- Server timezone offset with daylight saving flag
|
||||
- `Quality` -- Set to Good for alarm events
|
||||
|
||||
## Auto-subscription to Alarm Tags
|
||||
|
||||
After alarm condition nodes are created, `SubscribeAlarmTags` opens MXAccess subscriptions for three tags per alarm:
|
||||
|
||||
1. **InAlarm** (`Tag_001.Temperature.InAlarm`) -- The boolean trigger for alarm activation/deactivation.
|
||||
2. **Priority** (`Tag_001.Temperature.Priority`) -- Numeric priority that maps to OPC UA severity.
|
||||
3. **DescAttrName** (`Tag_001.Temperature.DescAttrName`) -- String description used as the alarm event message.
|
||||
|
||||
These subscriptions are opened unconditionally (not ref-counted) because they serve the server's own alarm tracking, not client-initiated monitoring. Tags that do not have corresponding variable nodes in `_tagToVariableNode` are skipped.
|
||||
|
||||
## EventNotifier Propagation
|
||||
|
||||
When a Galaxy object contains at least one alarm attribute, `EventNotifiers.SubscribeToEvents` is set on the object node **and all its ancestors** up to the root. This allows OPC UA clients to subscribe to events at any level in the hierarchy and receive alarm notifications from all descendants:
|
||||
|
||||
```csharp
|
||||
if (hasAlarms && _nodeMap.TryGetValue(obj.GobjectId, out var objNode))
|
||||
EnableEventNotifierUpChain(objNode);
|
||||
```
|
||||
|
||||
For example, an alarm on `TestMachine_001.SubObject.Temperature` will be visible to clients subscribed on `SubObject`, `TestMachine_001`, or the root `ZB` folder. The root `ZB` folder also has `EventNotifiers.SubscribeToEvents` enabled during initial construction.
|
||||
|
||||
## InAlarm Transition Detection in DispatchLoop
|
||||
|
||||
Alarm state changes are detected in the dispatch loop's Phase 1 (outside `Lock`), which runs on the background dispatch thread rather than the STA thread. This placement is intentional because the detection logic reads Priority and DescAttrName values from MXAccess, which would block the STA thread if done inside the `OnMxAccessDataChange` callback.
|
||||
|
||||
For each pending data change, the loop checks whether the address matches a key in `_alarmInAlarmTags`:
|
||||
|
||||
```csharp
|
||||
if (_alarmInAlarmTags.TryGetValue(address, out var alarmInfo))
|
||||
{
|
||||
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)
|
||||
...
|
||||
pendingAlarmEvents.Add((alarmInfo, newInAlarm));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The boolean coercion handles multiple value representations: `true`, integer `1`, or any non-zero integer. When the value changes state, Priority and DescAttrName are read synchronously from MXAccess to populate `CachedSeverity` and `CachedMessage`. These reads happen outside `Lock` because they call into the STA thread.
|
||||
|
||||
Priority values are clamped to the OPC UA severity range (1-1000). Both `int` and `short` types are handled.
|
||||
|
||||
## ReportAlarmEvent
|
||||
|
||||
`ReportAlarmEvent` runs inside `Lock` during Phase 2 of the dispatch loop. It updates the `AlarmConditionState` and generates an OPC UA event:
|
||||
|
||||
```csharp
|
||||
condition.SetActiveState(SystemContext, active);
|
||||
condition.Message.Value = new LocalizedText("en", message);
|
||||
condition.SetSeverity(SystemContext, (EventSeverity)severity);
|
||||
condition.Retain.Value = active || (condition.AckedState?.Id?.Value == false);
|
||||
```
|
||||
|
||||
Key behaviors:
|
||||
|
||||
- **Active state** -- Set to `true` on activation, `false` on clearing.
|
||||
- **Message** -- Uses `CachedMessage` (from DescAttrName) when available on activation. Falls back to a generated `"Alarm active: {SourceName}"` string. Cleared alarms always use `"Alarm cleared: {SourceName}"`.
|
||||
- **Severity** -- Set from `CachedSeverity`, which was read from the Priority tag.
|
||||
- **Retain** -- `true` while the alarm is active or unacknowledged. This keeps the condition visible in condition refresh responses.
|
||||
- **Acknowledged state** -- Reset to `false` when the alarm activates, requiring explicit client acknowledgment. When role-based auth is active, alarm acknowledgment requires the `AlarmAck` role on the session (checked via `GrantedRoleIds`). Users without this role receive `BadUserAccessDenied`.
|
||||
|
||||
The event is reported by walking up the notifier chain from the source variable's parent through all ancestor nodes. Each ancestor with `EventNotifier` set receives the event via `ReportEvent`, so clients subscribed at any level in the Galaxy hierarchy see alarm transitions from descendant objects.
|
||||
|
||||
## Condition Refresh Override
|
||||
|
||||
The `ConditionRefresh` override iterates all tracked alarms and queues retained conditions to the requesting monitored items:
|
||||
|
||||
```csharp
|
||||
public override ServiceResult ConditionRefresh(OperationContext context,
|
||||
IList<IEventMonitoredItem> monitoredItems)
|
||||
{
|
||||
foreach (var kvp in _alarmInAlarmTags)
|
||||
{
|
||||
var info = kvp.Value;
|
||||
if (info.ConditionNode == null || info.ConditionNode.Retain?.Value != true)
|
||||
continue;
|
||||
foreach (var item in monitoredItems)
|
||||
item.QueueEvent(info.ConditionNode);
|
||||
}
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
```
|
||||
|
||||
Only conditions where `Retain.Value == true` are included. This means only active or unacknowledged alarms appear in condition refresh responses, matching the OPC UA specification requirement that condition refresh returns the current state of all retained conditions.
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs` — capability contract + `AlarmEventArgs`
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs` — per-host fan-out + no-retry ack
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — `CapturingBuilder` + alarm forwarder
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs` — `VariableHandle.MarkAsAlarmCondition` + `ConditionSink`
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs` — Galaxy-specific alarm-event production
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
## Overview
|
||||
|
||||
`ZB.MOM.WW.OtOpcUa.Client.CLI` is a cross-platform command-line client for the LmxOpcUa OPC UA server. It targets .NET 10 and uses the shared `IOpcUaClientService` from `Client.Shared` for all OPC UA operations. Commands are routed and parsed by [CliFx](https://github.com/Tyrrrz/CliFx).
|
||||
`ZB.MOM.WW.OtOpcUa.Client.CLI` is a cross-platform command-line client for the OtOpcUa OPC UA server. It targets .NET 10 and uses the shared `IOpcUaClientService` from `Client.Shared` for all OPC UA operations. Commands are routed and parsed by [CliFx](https://github.com/Tyrrrz/CliFx).
|
||||
|
||||
The CLI is the primary tool for operators and developers to test and interact with the server from a terminal. It supports all core operations: connectivity testing, browsing, reading, writing, subscriptions, alarm monitoring, history reads, and redundancy queries.
|
||||
The CLI is the primary tool for operators and developers to test and interact with the server from a terminal. It supports all core operations: connectivity testing, browsing, reading, writing, subscriptions, alarm monitoring, history reads, and redundancy queries. Any driver surface exposed by the server (Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, OPC UA Client) is reachable through these commands — the CLI is driver-agnostic because everything below the OPC UA endpoint is.
|
||||
|
||||
## Build and Run
|
||||
|
||||
@@ -14,7 +14,7 @@ dotnet build
|
||||
dotnet run -- <command> [options]
|
||||
```
|
||||
|
||||
The executable name is `lmxopcua-cli`.
|
||||
The executable name is `otopcua-cli`. Dev boxes carrying a pre-task-#208 install may still have the legacy `{LocalAppData}/LmxOpcUaClient/` folder on disk; on first launch of any post-#208 CLI or UI build, `ClientStoragePaths` (`src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs`) migrates it to `{LocalAppData}/OtOpcUaClient/` automatically so trusted certificates + saved settings survive the rename.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -46,7 +46,7 @@ All commands accept these options:
|
||||
When `-U` and `-P` are provided, the shared service passes a `UserIdentity(username, password)` to the OPC UA session. Without credentials, anonymous identity is used.
|
||||
|
||||
```bash
|
||||
lmxopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operator -P op123
|
||||
otopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operator -P op123
|
||||
```
|
||||
|
||||
### Failover
|
||||
@@ -54,20 +54,20 @@ lmxopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U opera
|
||||
When `-F` is provided, the shared service tries the primary URL first, then each failover URL in order. For long-running commands (`subscribe`, `alarms`), the service monitors the session via keep-alive and automatically reconnects to the next available server on failure.
|
||||
|
||||
```bash
|
||||
lmxopcua-cli connect -u opc.tcp://localhost:4840/LmxOpcUa -F opc.tcp://localhost:4841/LmxOpcUa
|
||||
otopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -F opc.tcp://localhost:4841/OtOpcUa
|
||||
```
|
||||
|
||||
### Transport Security
|
||||
|
||||
When `sign` or `encrypt` is specified, the shared service:
|
||||
|
||||
1. Ensures a client application certificate exists under `{LocalAppData}/LmxOpcUaClient/pki/` (auto-created if missing)
|
||||
1. Ensures a client application certificate exists under `{LocalAppData}/OtOpcUaClient/pki/` (auto-created if missing; pre-rename `LmxOpcUaClient/` is migrated in place on first launch)
|
||||
2. Discovers server endpoints and selects one matching the requested security mode
|
||||
3. Prefers `Basic256Sha256` when multiple matching endpoints exist
|
||||
4. Fails with a clear error if no matching endpoint is found
|
||||
|
||||
```bash
|
||||
lmxopcua-cli browse -u opc.tcp://localhost:4840/LmxOpcUa -S encrypt -U admin -P secret -r -d 2
|
||||
otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -S encrypt -U admin -P secret -r -d 2
|
||||
```
|
||||
|
||||
### Verbose Logging
|
||||
@@ -81,14 +81,14 @@ The `--verbose` flag switches Serilog output from `Warning` to `Debug` level, sh
|
||||
Tests connectivity to an OPC UA server. Creates a session, prints connection metadata, and disconnects.
|
||||
|
||||
```bash
|
||||
lmxopcua-cli connect -u opc.tcp://localhost:4840/LmxOpcUa -U admin -P admin123
|
||||
otopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```text
|
||||
Connected to: opc.tcp://localhost:4840/LmxOpcUa
|
||||
Server: LmxOpcUa
|
||||
Connected to: opc.tcp://localhost:4840/OtOpcUa
|
||||
Server: OtOpcUa Server
|
||||
Security Mode: None
|
||||
Security Policy: http://opcfoundation.org/UA/SecurityPolicy#None
|
||||
Connection successful.
|
||||
@@ -99,7 +99,7 @@ Connection successful.
|
||||
Reads the current value of a single node and prints the value, status code, and timestamps.
|
||||
|
||||
```bash
|
||||
lmxopcua-cli read -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=3;s=DEV.ScanState" -U admin -P admin123
|
||||
otopcua-cli read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=3;s=DEV.ScanState" -U admin -P admin123
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
@@ -121,7 +121,7 @@ Server Time: 2026-03-30T19:58:38.0971257Z
|
||||
Writes a value to a node. The shared service reads the current value first to determine the target data type, then converts the supplied string value using `ValueConverter.ConvertValue()`.
|
||||
|
||||
```bash
|
||||
lmxopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42
|
||||
otopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
@@ -135,10 +135,10 @@ Browses the OPC UA address space starting from the Objects folder or a specified
|
||||
|
||||
```bash
|
||||
# Browse top-level Objects folder
|
||||
lmxopcua-cli browse -u opc.tcp://localhost:4840/LmxOpcUa -U admin -P admin123
|
||||
otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
|
||||
|
||||
# Browse a specific node recursively to depth 3
|
||||
lmxopcua-cli browse -u opc.tcp://localhost:4840/LmxOpcUa -U admin -P admin123 -r -d 3 -n "ns=3;s=ZB"
|
||||
otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 -r -d 3 -n "ns=3;s=ZB"
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
@@ -152,7 +152,7 @@ lmxopcua-cli browse -u opc.tcp://localhost:4840/LmxOpcUa -U admin -P admin123 -r
|
||||
Monitors a node for value changes using OPC UA subscriptions. Prints each data change notification with timestamp, value, and status code. Runs until Ctrl+C, then unsubscribes and disconnects cleanly.
|
||||
|
||||
```bash
|
||||
lmxopcua-cli subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -i 500
|
||||
otopcua-cli subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -i 500
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
@@ -166,12 +166,12 @@ Reads historical data from a node. Supports raw history reads and aggregate (pro
|
||||
|
||||
```bash
|
||||
# Raw history
|
||||
lmxopcua-cli historyread -u opc.tcp://localhost:4840/LmxOpcUa \
|
||||
otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
|
||||
-n "ns=1;s=TestMachine_001.TestHistoryValue" \
|
||||
--start "2026-03-25" --end "2026-03-30"
|
||||
|
||||
# Aggregate: 1-hour average
|
||||
lmxopcua-cli historyread -u opc.tcp://localhost:4840/LmxOpcUa \
|
||||
otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
|
||||
-n "ns=1;s=TestMachine_001.TestHistoryValue" \
|
||||
--start "2026-03-25" --end "2026-03-30" \
|
||||
--aggregate Average --interval 3600000
|
||||
@@ -203,10 +203,10 @@ Subscribes to alarm events on a node. Prints structured alarm output including s
|
||||
|
||||
```bash
|
||||
# Subscribe to alarm events on the Server node
|
||||
lmxopcua-cli alarms -u opc.tcp://localhost:4840/LmxOpcUa
|
||||
otopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa
|
||||
|
||||
# Subscribe to a specific source node with condition refresh
|
||||
lmxopcua-cli alarms -u opc.tcp://localhost:4840/LmxOpcUa \
|
||||
otopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa \
|
||||
-n "ns=1;s=TestMachine_001" --refresh
|
||||
```
|
||||
|
||||
@@ -221,7 +221,7 @@ lmxopcua-cli alarms -u opc.tcp://localhost:4840/LmxOpcUa \
|
||||
Reads the OPC UA redundancy state from a server: redundancy mode, service level, server URIs, and application URI.
|
||||
|
||||
```bash
|
||||
lmxopcua-cli redundancy -u opc.tcp://localhost:4840/LmxOpcUa -U admin -P admin123
|
||||
otopcua-cli redundancy -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123
|
||||
```
|
||||
|
||||
Example output:
|
||||
@@ -230,9 +230,9 @@ Example output:
|
||||
Redundancy Mode: Warm
|
||||
Service Level: 200
|
||||
Server URIs:
|
||||
- urn:localhost:LmxOpcUa:instance1
|
||||
- urn:localhost:LmxOpcUa:instance2
|
||||
Application URI: urn:localhost:LmxOpcUa:instance1
|
||||
- urn:localhost:OtOpcUa:instance1
|
||||
- urn:localhost:OtOpcUa:instance2
|
||||
Application URI: urn:localhost:OtOpcUa:instance1
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
`ZB.MOM.WW.OtOpcUa.Client.UI` is a cross-platform Avalonia desktop application for connecting to and interacting with the LmxOpcUa OPC UA server. It targets .NET 10 and uses the shared `IOpcUaClientService` from `Client.Shared` for all OPC UA operations.
|
||||
`ZB.MOM.WW.OtOpcUa.Client.UI` is a cross-platform Avalonia desktop application for connecting to and interacting with the OtOpcUa OPC UA server. It targets .NET 10 and uses the shared `IOpcUaClientService` from `Client.Shared` for all OPC UA operations.
|
||||
|
||||
The UI provides a single-window interface for browsing the address space, reading and writing values, monitoring live subscriptions, managing alarms, and querying historical data.
|
||||
|
||||
@@ -43,7 +43,7 @@ The application uses a single-window layout with five main areas:
|
||||
│ │ │ ││
|
||||
│ (lazy-load) │ └──────────────────────────────────────────────┘│
|
||||
├──────────────┴──────────────────────────────────────────────┤
|
||||
│ Connected to opc.tcp://... | LmxOpcUa | Session: ... | 3 subs│
|
||||
│ Connected to opc.tcp://... | OtOpcUa Server | Session: ... | 3 subs│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -55,7 +55,7 @@ The top bar provides the endpoint URL, Connect, and Disconnect buttons. The **Co
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Endpoint URL | OPC UA server endpoint (e.g., `opc.tcp://localhost:4840/LmxOpcUa`) |
|
||||
| Endpoint URL | OPC UA server endpoint (e.g., `opc.tcp://localhost:4840/OtOpcUa`) |
|
||||
| Username / Password | Credentials for `UserName` token authentication |
|
||||
| Security Mode | Transport security: None, Sign, SignAndEncrypt |
|
||||
| Failover URLs | Comma-separated backup endpoints for redundancy failover |
|
||||
@@ -65,7 +65,7 @@ The top bar provides the endpoint URL, Connect, and Disconnect buttons. The **Co
|
||||
|
||||
### Settings Persistence
|
||||
|
||||
Connection settings are saved to `{LocalAppData}/LmxOpcUaClient/settings.json` after each successful connection and on window close. The settings are reloaded on next launch, including:
|
||||
Connection settings are saved to `{LocalAppData}/OtOpcUaClient/settings.json` after each successful connection and on window close. Dev boxes upgrading from a pre-task-#208 build still have the legacy `LmxOpcUaClient/` folder on disk; `ClientStoragePaths` in `Client.Shared` moves it to the canonical path on first launch so existing trusted certs + saved settings persist without operator action. The settings are reloaded on next launch, including:
|
||||
|
||||
- All connection parameters
|
||||
- Active subscription node IDs (restored after reconnection)
|
||||
|
||||
@@ -1,370 +1,141 @@
|
||||
# Configuration
|
||||
|
||||
## Overview
|
||||
## Two-layer model
|
||||
|
||||
The service loads configuration from `appsettings.json` at startup using the Microsoft.Extensions.Configuration stack. `AppConfiguration` is the root holder class that aggregates typed sections: `OpcUa`, `MxAccess`, `GalaxyRepository`, `Dashboard`, `Historian`, `Authentication`, and `Security`. Each section binds to a dedicated POCO class with sensible defaults, so the service runs with zero configuration on a standard deployment.
|
||||
OtOpcUa configuration is split into two layers:
|
||||
|
||||
## Config Binding Pattern
|
||||
| Layer | Where | Scope | Edited by |
|
||||
|---|---|---|---|
|
||||
| **Bootstrap** | `appsettings.json` per process | Enough to start the process and reach the Config DB | Local file edit + process restart |
|
||||
| **Authoritative config** | Config DB (SQL Server) via `OtOpcUaConfigDbContext` | Clusters, namespaces, UNS hierarchy, equipment, tags, driver instances, ACLs, role grants, poll groups | Admin UI draft/publish workflow |
|
||||
|
||||
The production constructor in `OpcUaService` builds the configuration pipeline and binds each JSON section to its typed class:
|
||||
The rule: if the setting describes *how the process connects to the rest of the world* (Config DB connection string, LDAP bind, transport security profile, node identity, logging), it lives in `appsettings.json`. If it describes *what the fleet does* (clusters, drivers, tags, UNS, ACLs), it lives in the Config DB and is edited through the Admin UI.
|
||||
|
||||
```csharp
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.json", optional: false)
|
||||
.AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"}.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
---
|
||||
|
||||
_config = new AppConfiguration();
|
||||
configuration.GetSection("OpcUa").Bind(_config.OpcUa);
|
||||
configuration.GetSection("MxAccess").Bind(_config.MxAccess);
|
||||
configuration.GetSection("GalaxyRepository").Bind(_config.GalaxyRepository);
|
||||
configuration.GetSection("Dashboard").Bind(_config.Dashboard);
|
||||
configuration.GetSection("Historian").Bind(_config.Historian);
|
||||
configuration.GetSection("Authentication").Bind(_config.Authentication);
|
||||
configuration.GetSection("Security").Bind(_config.Security);
|
||||
```
|
||||
## Bootstrap configuration (`appsettings.json`)
|
||||
|
||||
This pattern uses `IConfiguration.GetSection().Bind()` rather than `IOptions<T>` because the service targets .NET Framework 4.8, where the full dependency injection container is not used.
|
||||
Each of the three processes (Server, Admin, Galaxy.Host) reads its own `appsettings.json` plus environment overrides.
|
||||
|
||||
## Environment-Specific Overrides
|
||||
### OtOpcUa Server — `src/ZB.MOM.WW.OtOpcUa.Server/appsettings.json`
|
||||
|
||||
The configuration pipeline supports three layers of override, applied in order:
|
||||
Bootstrap-only. `Program.cs` reads four top-level sections:
|
||||
|
||||
1. `appsettings.json` -- base configuration (required)
|
||||
2. `appsettings.{DOTNET_ENVIRONMENT}.json` -- environment-specific overlay (optional)
|
||||
3. Environment variables -- highest priority, useful for deployment automation
|
||||
| Section | Keys | Purpose |
|
||||
|---|---|---|
|
||||
| `Node` | `NodeId`, `ClusterId`, `ConfigDbConnectionString`, `LocalCachePath` | Identity + path to the Config DB + LiteDB offline cache path. |
|
||||
| `OpcUaServer` | `EndpointUrl`, `ApplicationName`, `ApplicationUri`, `PkiStoreRoot`, `AutoAcceptUntrustedClientCertificates`, `SecurityProfile` | OPC UA endpoint + transport security. See [`security.md`](security.md). |
|
||||
| `OpcUaServer:Ldap` | `Enabled`, `Server`, `Port`, `UseTls`, `AllowInsecureLdap`, `SearchBase`, `ServiceAccountDn`, `ServiceAccountPassword`, `GroupToRole`, `UserNameAttribute`, `GroupAttribute` | LDAP auth for OPC UA UserName tokens. See [`security.md`](security.md). |
|
||||
| `Serilog` | Standard Serilog keys + `WriteJson` bool | Logging verbosity + optional JSON file sink for SIEM ingest. |
|
||||
| `Authorization` | `StrictMode` (bool) | Flip `true` to fail-closed on sessions lacking LDAP group metadata. Default false during ACL rollouts. |
|
||||
| `Metrics:Prometheus:Enabled` | bool | Toggles the `/metrics` endpoint. |
|
||||
|
||||
Set the `DOTNET_ENVIRONMENT` variable to load a named overlay file. For example, setting `DOTNET_ENVIRONMENT=Staging` loads `appsettings.Staging.json` if it exists.
|
||||
|
||||
Environment variables follow the standard `Section__Property` naming convention. For example, `OpcUa__Port=5840` overrides the OPC UA port.
|
||||
|
||||
## Configuration Sections
|
||||
|
||||
### OpcUa
|
||||
|
||||
Controls the OPC UA server endpoint and session limits. Defined in `OpcUaConfiguration`.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `BindAddress` | `string` | `"0.0.0.0"` | IP address or hostname the server binds to. Use `0.0.0.0` for all interfaces, `localhost` for local-only, or a specific IP |
|
||||
| `Port` | `int` | `4840` | TCP port the OPC UA server listens on |
|
||||
| `EndpointPath` | `string` | `"/LmxOpcUa"` | Path appended to the host URI |
|
||||
| `ServerName` | `string` | `"LmxOpcUa"` | Server name presented to OPC UA clients |
|
||||
| `GalaxyName` | `string` | `"ZB"` | Galaxy name used as the OPC UA namespace |
|
||||
| `MaxSessions` | `int` | `100` | Maximum simultaneous OPC UA sessions |
|
||||
| `SessionTimeoutMinutes` | `int` | `30` | Idle session timeout in minutes |
|
||||
| `AlarmTrackingEnabled` | `bool` | `false` | Enables `AlarmConditionState` nodes for alarm attributes |
|
||||
| `AlarmFilter.ObjectFilters` | `List<string>` | `[]` | Wildcard template-name patterns (with `*`) that scope alarm tracking to matching objects and their descendants. Empty list disables filtering. See [Alarm Tracking](AlarmTracking.md#template-based-alarm-object-filter) |
|
||||
| `ApplicationUri` | `string?` | `null` | Explicit application URI for this server instance. Required when redundancy is enabled. Defaults to `urn:{GalaxyName}:LmxOpcUa` when null |
|
||||
|
||||
### MxAccess
|
||||
|
||||
Controls the MXAccess runtime connection used for live tag reads and writes. Defined in `MxAccessConfiguration`.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `ClientName` | `string` | `"LmxOpcUa"` | Client name registered with MXAccess |
|
||||
| `NodeName` | `string?` | `null` | Optional Galaxy node name to target |
|
||||
| `GalaxyName` | `string?` | `null` | Optional Galaxy name for MXAccess reference resolution |
|
||||
| `ReadTimeoutSeconds` | `int` | `5` | Maximum wait for a live tag read |
|
||||
| `WriteTimeoutSeconds` | `int` | `5` | Maximum wait for a write acknowledgment |
|
||||
| `MaxConcurrentOperations` | `int` | `10` | Cap on concurrent MXAccess operations |
|
||||
| `MonitorIntervalSeconds` | `int` | `5` | Connectivity monitor probe interval |
|
||||
| `AutoReconnect` | `bool` | `true` | Automatically re-establish dropped MXAccess sessions |
|
||||
| `ProbeTag` | `string?` | `null` | Optional tag used to verify the runtime returns fresh data |
|
||||
| `ProbeStaleThresholdSeconds` | `int` | `60` | Seconds a probe value may remain unchanged before the connection is considered stale |
|
||||
| `RuntimeStatusProbesEnabled` | `bool` | `true` | Advises `<Host>.ScanState` on every deployed `$WinPlatform` and `$AppEngine` to track per-host runtime state. Drives the Galaxy Runtime dashboard panel, HealthCheck Rule 2e, and the Read-path short-circuit that invalidates OPC UA variable quality when a host is Stopped. Set `false` to return to legacy behavior where host state is invisible and the bridge serves whatever quality MxAccess reports for individual tags. See [MXAccess Bridge](MxAccessBridge.md#per-host-runtime-status-probes-hostscanstate) |
|
||||
| `RuntimeStatusUnknownTimeoutSeconds` | `int` | `15` | Maximum seconds to wait for the initial probe callback before marking a host as Stopped. Only applies to the Unknown → Stopped transition; Running hosts never time out because `ScanState` is delivered on-change only. A value below 5s triggers a validator warning |
|
||||
| `RequestTimeoutSeconds` | `int` | `30` | Outer safety timeout applied to sync-over-async MxAccess operations invoked from the OPC UA stack thread (Read, Write, address-space rebuild probe sync). Backstop for the inner `ReadTimeoutSeconds` / `WriteTimeoutSeconds`. A timed-out operation returns `BadTimeout`. Validator rejects values < 1 and warns if set below the inner Read/Write timeouts. See [MXAccess Bridge](MxAccessBridge.md#request-timeout-safety-backstop). Stability review 2026-04-13 Finding 3 |
|
||||
|
||||
### GalaxyRepository
|
||||
|
||||
Controls the Galaxy repository database connection used to build the OPC UA address space. Defined in `GalaxyRepositoryConfiguration`.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `ConnectionString` | `string` | `"Server=localhost;Database=ZB;Integrated Security=true;"` | SQL Server connection string for the Galaxy database |
|
||||
| `ChangeDetectionIntervalSeconds` | `int` | `30` | How often the service polls for Galaxy deploy changes |
|
||||
| `CommandTimeoutSeconds` | `int` | `30` | SQL command timeout for repository queries |
|
||||
| `ExtendedAttributes` | `bool` | `false` | Load extended Galaxy attribute metadata into the OPC UA model |
|
||||
| `Scope` | `GalaxyScope` | `"Galaxy"` | Controls how much of the Galaxy hierarchy is loaded. `Galaxy` loads all deployed objects (default). `LocalPlatform` loads only objects hosted by the platform deployed on this machine. See [Galaxy Repository — Platform Scope Filter](GalaxyRepository.md#platform-scope-filter) |
|
||||
| `PlatformName` | `string?` | `null` | Explicit platform hostname for `LocalPlatform` filtering. When null, uses `Environment.MachineName`. Only used when `Scope` is `LocalPlatform` |
|
||||
|
||||
### Dashboard
|
||||
|
||||
Controls the embedded HTTP status dashboard. Defined in `DashboardConfiguration`.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `Enabled` | `bool` | `true` | Whether the status dashboard is hosted |
|
||||
| `Port` | `int` | `8081` | HTTP port for the dashboard endpoint |
|
||||
| `RefreshIntervalSeconds` | `int` | `10` | HTML auto-refresh interval in seconds |
|
||||
|
||||
### Historian
|
||||
|
||||
Controls the Wonderware Historian SDK connection for OPC UA historical data access. Defined in `HistorianConfiguration`.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `Enabled` | `bool` | `false` | Enables OPC UA historical data access |
|
||||
| `ServerName` | `string` | `"localhost"` | Single Historian server hostname used when `ServerNames` is empty. Preserved for backward compatibility with pre-cluster deployments |
|
||||
| `ServerNames` | `List<string>` | `[]` | Ordered list of Historian cluster nodes. When non-empty, supersedes `ServerName` and enables read-only cluster failover. See [Historical Data Access](HistoricalDataAccess.md#read-only-cluster-failover) |
|
||||
| `FailureCooldownSeconds` | `int` | `60` | How long a failed cluster node is skipped before being re-tried. Zero disables the cooldown |
|
||||
| `IntegratedSecurity` | `bool` | `true` | Use Windows authentication |
|
||||
| `UserName` | `string?` | `null` | Username when `IntegratedSecurity` is false |
|
||||
| `Password` | `string?` | `null` | Password when `IntegratedSecurity` is false |
|
||||
| `Port` | `int` | `32568` | Historian TCP port |
|
||||
| `CommandTimeoutSeconds` | `int` | `30` | SDK packet timeout in seconds (inner async bound) |
|
||||
| `RequestTimeoutSeconds` | `int` | `60` | Outer safety timeout applied to sync-over-async Historian operations invoked from the OPC UA stack thread (`HistoryReadRaw`, `HistoryReadProcessed`, `HistoryReadAtTime`, `HistoryReadEvents`). Backstop for `CommandTimeoutSeconds`; a timed-out read returns `BadTimeout`. Validator rejects values < 1 and warns if set below `CommandTimeoutSeconds`. Stability review 2026-04-13 Finding 3 |
|
||||
| `MaxValuesPerRead` | `int` | `10000` | Maximum values returned per `HistoryRead` request |
|
||||
|
||||
### Authentication
|
||||
|
||||
Controls user authentication and write authorization for the OPC UA server. Defined in `AuthenticationConfiguration`.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `AllowAnonymous` | `bool` | `true` | Accepts anonymous client connections when `true` |
|
||||
| `AnonymousCanWrite` | `bool` | `true` | Permits anonymous users to write when `true` |
|
||||
|
||||
#### LDAP Authentication
|
||||
|
||||
When `Ldap.Enabled` is `true`, credentials are validated against the configured LDAP server and group membership determines OPC UA permissions.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `Ldap.Enabled` | `bool` | `false` | Enables LDAP authentication |
|
||||
| `Ldap.Host` | `string` | `localhost` | LDAP server hostname |
|
||||
| `Ldap.Port` | `int` | `3893` | LDAP server port |
|
||||
| `Ldap.BaseDN` | `string` | `dc=lmxopcua,dc=local` | Base DN for LDAP operations |
|
||||
| `Ldap.BindDnTemplate` | `string` | `cn={username},dc=lmxopcua,dc=local` | Bind DN template (`{username}` is replaced) |
|
||||
| `Ldap.ServiceAccountDn` | `string` | `""` | Service account DN for group lookups |
|
||||
| `Ldap.ServiceAccountPassword` | `string` | `""` | Service account password |
|
||||
| `Ldap.TimeoutSeconds` | `int` | `5` | Connection timeout |
|
||||
| `Ldap.ReadOnlyGroup` | `string` | `ReadOnly` | LDAP group granting read-only access |
|
||||
| `Ldap.WriteOperateGroup` | `string` | `WriteOperate` | LDAP group granting write access for FreeAccess/Operate attributes |
|
||||
| `Ldap.WriteTuneGroup` | `string` | `WriteTune` | LDAP group granting write access for Tune attributes |
|
||||
| `Ldap.WriteConfigureGroup` | `string` | `WriteConfigure` | LDAP group granting write access for Configure attributes |
|
||||
| `Ldap.AlarmAckGroup` | `string` | `AlarmAck` | LDAP group granting alarm acknowledgment |
|
||||
|
||||
#### Permission Model
|
||||
|
||||
When LDAP is enabled, LDAP group membership is mapped to OPC UA session role NodeIds during authentication. All authenticated LDAP users can browse and read nodes regardless of group membership. Groups grant additional permissions:
|
||||
|
||||
| LDAP Group | Permission |
|
||||
|---|---|
|
||||
| ReadOnly | No additional permissions (read-only access) |
|
||||
| WriteOperate | Write FreeAccess and Operate attributes |
|
||||
| WriteTune | Write Tune attributes |
|
||||
| WriteConfigure | Write Configure attributes |
|
||||
| AlarmAck | Acknowledge alarms |
|
||||
|
||||
Users can belong to multiple groups. The `admin` user in the default GLAuth configuration belongs to all three groups.
|
||||
|
||||
Write access depends on both the user's role and the Galaxy attribute's security classification. See the [Effective Permission Matrix](Security.md#effective-permission-matrix) in the Security Guide for the full breakdown.
|
||||
|
||||
Example configuration:
|
||||
|
||||
```json
|
||||
"Authentication": {
|
||||
"AllowAnonymous": true,
|
||||
"AnonymousCanWrite": false,
|
||||
"Ldap": {
|
||||
"Enabled": true,
|
||||
"Host": "localhost",
|
||||
"Port": 3893,
|
||||
"BaseDN": "dc=lmxopcua,dc=local",
|
||||
"BindDnTemplate": "cn={username},dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
"TimeoutSeconds": 5,
|
||||
"ReadOnlyGroup": "ReadOnly",
|
||||
"WriteOperateGroup": "WriteOperate",
|
||||
"WriteTuneGroup": "WriteTune",
|
||||
"WriteConfigureGroup": "WriteConfigure",
|
||||
"AlarmAckGroup": "AlarmAck"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
Controls OPC UA transport security profiles and certificate handling. Defined in `SecurityProfileConfiguration`. See [Security Guide](security.md) for detailed usage.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `Profiles` | `List<string>` | `["None"]` | Security profiles to expose. Valid: `None`, `Basic256Sha256-Sign`, `Basic256Sha256-SignAndEncrypt`, `Aes128_Sha256_RsaOaep-Sign`, `Aes128_Sha256_RsaOaep-SignAndEncrypt`, `Aes256_Sha256_RsaPss-Sign`, `Aes256_Sha256_RsaPss-SignAndEncrypt` |
|
||||
| `AutoAcceptClientCertificates` | `bool` | `true` | Auto-accept untrusted client certificates. Set to `false` in production |
|
||||
| `RejectSHA1Certificates` | `bool` | `true` | Reject client certificates signed with SHA-1 |
|
||||
| `MinimumCertificateKeySize` | `int` | `2048` | Minimum RSA key size for client certificates |
|
||||
| `PkiRootPath` | `string?` | `null` | Override for PKI root directory. Defaults to `%LOCALAPPDATA%\OPC Foundation\pki` |
|
||||
| `CertificateSubject` | `string?` | `null` | Override for server certificate subject. Defaults to `CN={ServerName}, O=ZB MOM, DC=localhost` |
|
||||
|
||||
Example — production deployment with encrypted transport:
|
||||
|
||||
```json
|
||||
"Security": {
|
||||
"Profiles": ["Basic256Sha256-SignAndEncrypt"],
|
||||
"AutoAcceptClientCertificates": false,
|
||||
"RejectSHA1Certificates": true,
|
||||
"MinimumCertificateKeySize": 2048
|
||||
}
|
||||
```
|
||||
|
||||
### Redundancy
|
||||
|
||||
Controls non-transparent OPC UA redundancy. Defined in `RedundancyConfiguration`. See [Redundancy Guide](Redundancy.md) for detailed usage.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `Enabled` | `bool` | `false` | Enables redundancy mode and ServiceLevel computation |
|
||||
| `Mode` | `string` | `"Warm"` | Redundancy mode: `Warm` or `Hot` |
|
||||
| `Role` | `string` | `"Primary"` | Instance role: `Primary` (higher ServiceLevel) or `Secondary` |
|
||||
| `ServerUris` | `List<string>` | `[]` | ApplicationUri values for all servers in the redundant set |
|
||||
| `ServiceLevelBase` | `int` | `200` | Base ServiceLevel when healthy (1-255). Secondary receives base - 50 |
|
||||
|
||||
Example — two-instance redundant pair (Primary):
|
||||
|
||||
```json
|
||||
"Redundancy": {
|
||||
"Enabled": true,
|
||||
"Mode": "Warm",
|
||||
"Role": "Primary",
|
||||
"ServerUris": ["urn:localhost:LmxOpcUa:instance1", "urn:localhost:LmxOpcUa:instance2"],
|
||||
"ServiceLevelBase": 200
|
||||
}
|
||||
```
|
||||
|
||||
## Feature Flags
|
||||
|
||||
Three boolean properties act as feature flags that control optional subsystems:
|
||||
|
||||
- **`OpcUa.AlarmTrackingEnabled`** -- When `true`, the node manager creates `AlarmConditionState` nodes for alarm attributes and monitors `InAlarm` transitions. Disabled by default because alarm tracking adds per-attribute overhead.
|
||||
- **`OpcUa.AlarmFilter.ObjectFilters`** -- List of wildcard template-name patterns that scope alarm tracking to matching objects and their descendants. An empty list preserves the current unfiltered behavior; a non-empty list includes an object only when any name in its template derivation chain matches any pattern, then propagates the inclusion to every descendant in the containment hierarchy. `*` is the only wildcard, matching is case-insensitive, and the Galaxy `$` prefix on template names is normalized so operators can write `TestMachine*` instead of `$TestMachine*`. Each list entry may itself contain comma-separated patterns (`"TestMachine*, Pump_*"`) for convenience. When the list is non-empty but `AlarmTrackingEnabled` is `false`, the validator emits a warning because the filter has no effect. See [Alarm Tracking](AlarmTracking.md#template-based-alarm-object-filter) for the full matching algorithm and telemetry.
|
||||
- **`Historian.Enabled`** -- When `true`, the service calls `HistorianPluginLoader.TryLoad(config)` to load the `ZB.MOM.WW.OtOpcUa.Historian.Aveva` plugin from the `Historian/` subfolder next to the host exe and registers the resulting `IHistorianDataSource` with the OPC UA server host. Disabled by default because not all deployments have a Historian instance -- when disabled the plugin is not probed and the Wonderware SDK DLLs are not required on the host. If the flag is `true` but the plugin or its SDK dependencies cannot be loaded, the server still starts and every history read returns `BadHistoryOperationUnsupported` with a warning in the log.
|
||||
- **`GalaxyRepository.ExtendedAttributes`** -- When `true`, the repository loads additional Galaxy attribute metadata beyond the core set needed for the address space. Disabled by default to minimize startup query time.
|
||||
- **`GalaxyRepository.Scope`** -- When set to `LocalPlatform`, the repository filters the hierarchy and attributes to only include objects hosted by the platform whose `node_name` matches this machine (or the explicit `PlatformName` override). Ancestor areas are retained to keep the browse tree connected. Default is `Galaxy` (load everything). See [Galaxy Repository — Platform Scope Filter](GalaxyRepository.md#platform-scope-filter).
|
||||
|
||||
## Configuration Validation
|
||||
|
||||
`ConfigurationValidator.ValidateAndLog()` runs at the start of `OpcUaService.Start()`. It logs every resolved configuration value at `Information` level and validates required constraints:
|
||||
|
||||
- `OpcUa.Port` must be between 1 and 65535
|
||||
- `OpcUa.GalaxyName` must not be empty
|
||||
- `MxAccess.ClientName` must not be empty
|
||||
- `GalaxyRepository.ConnectionString` must not be empty
|
||||
- `Security.MinimumCertificateKeySize` must be at least 2048
|
||||
- Unknown security profile names are logged as warnings
|
||||
- `AutoAcceptClientCertificates = true` emits a warning
|
||||
- Only-`None` profile configuration emits a warning
|
||||
- `OpcUa.AlarmFilter.ObjectFilters` is non-empty while `OpcUa.AlarmTrackingEnabled = false` emits a warning (filter has no effect)
|
||||
- `Historian.ServerName` (or `Historian.ServerNames`) must not be empty when `Historian.Enabled = true`
|
||||
- `Historian.FailureCooldownSeconds` must be zero or positive
|
||||
- `Historian.ServerName` is set alongside a non-empty `Historian.ServerNames` emits a warning (single ServerName is ignored)
|
||||
- `MxAccess.RuntimeStatusUnknownTimeoutSeconds` below 5s emits a warning (below the reasonable floor for MxAccess initial-resolution latency)
|
||||
- `OpcUa.ApplicationUri` must be set when `Redundancy.Enabled = true`
|
||||
- `Redundancy.ServiceLevelBase` must be between 1 and 255
|
||||
- `Redundancy.ServerUris` should contain at least 2 entries when enabled
|
||||
- Local `ApplicationUri` should appear in `Redundancy.ServerUris`
|
||||
|
||||
If validation fails, the service throws `InvalidOperationException` and does not start.
|
||||
|
||||
## Test Constructor Pattern
|
||||
|
||||
`OpcUaService` provides an `internal` constructor that accepts pre-built dependencies instead of loading `appsettings.json`:
|
||||
|
||||
```csharp
|
||||
internal OpcUaService(
|
||||
AppConfiguration config,
|
||||
IMxProxy? mxProxy,
|
||||
IGalaxyRepository? galaxyRepository,
|
||||
IMxAccessClient? mxAccessClientOverride = null,
|
||||
bool hasMxAccessClientOverride = false)
|
||||
```
|
||||
|
||||
Integration tests use this constructor to inject substitute implementations of `IMxProxy`, `IGalaxyRepository`, and `IMxAccessClient`, bypassing the STA thread, COM interop, and SQL Server dependencies. The `hasMxAccessClientOverride` flag tells the service to use the injected `IMxAccessClient` directly instead of creating one from the `IMxProxy` on the STA thread.
|
||||
|
||||
## Example appsettings.json
|
||||
Minimal example:
|
||||
|
||||
```json
|
||||
{
|
||||
"OpcUa": {
|
||||
"BindAddress": "0.0.0.0",
|
||||
"Port": 4840,
|
||||
"EndpointPath": "/LmxOpcUa",
|
||||
"ServerName": "LmxOpcUa",
|
||||
"GalaxyName": "ZB",
|
||||
"MaxSessions": 100,
|
||||
"SessionTimeoutMinutes": 30,
|
||||
"AlarmTrackingEnabled": false,
|
||||
"AlarmFilter": {
|
||||
"ObjectFilters": []
|
||||
},
|
||||
"ApplicationUri": null
|
||||
"Serilog": { "MinimumLevel": "Information" },
|
||||
"Node": {
|
||||
"NodeId": "node-dev-a",
|
||||
"ClusterId": "cluster-dev",
|
||||
"ConfigDbConnectionString": "Server=localhost,14330;Database=OtOpcUaConfig;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
|
||||
"LocalCachePath": "config_cache.db"
|
||||
},
|
||||
"MxAccess": {
|
||||
"ClientName": "LmxOpcUa",
|
||||
"NodeName": null,
|
||||
"GalaxyName": null,
|
||||
"ReadTimeoutSeconds": 5,
|
||||
"WriteTimeoutSeconds": 5,
|
||||
"MaxConcurrentOperations": 10,
|
||||
"MonitorIntervalSeconds": 5,
|
||||
"AutoReconnect": true,
|
||||
"ProbeTag": null,
|
||||
"ProbeStaleThresholdSeconds": 60,
|
||||
"RuntimeStatusProbesEnabled": true,
|
||||
"RuntimeStatusUnknownTimeoutSeconds": 15,
|
||||
"RequestTimeoutSeconds": 30
|
||||
},
|
||||
"GalaxyRepository": {
|
||||
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=true;",
|
||||
"ChangeDetectionIntervalSeconds": 30,
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"ExtendedAttributes": false,
|
||||
"Scope": "Galaxy",
|
||||
"PlatformName": null
|
||||
},
|
||||
"Dashboard": {
|
||||
"Enabled": true,
|
||||
"Port": 8081,
|
||||
"RefreshIntervalSeconds": 10
|
||||
},
|
||||
"Historian": {
|
||||
"Enabled": false,
|
||||
"ServerName": "localhost",
|
||||
"ServerNames": [],
|
||||
"FailureCooldownSeconds": 60,
|
||||
"IntegratedSecurity": true,
|
||||
"UserName": null,
|
||||
"Password": null,
|
||||
"Port": 32568,
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"RequestTimeoutSeconds": 60,
|
||||
"MaxValuesPerRead": 10000
|
||||
},
|
||||
"Authentication": {
|
||||
"AllowAnonymous": true,
|
||||
"AnonymousCanWrite": true,
|
||||
"Ldap": {
|
||||
"Enabled": false
|
||||
}
|
||||
},
|
||||
"Security": {
|
||||
"Profiles": ["None"],
|
||||
"AutoAcceptClientCertificates": true,
|
||||
"RejectSHA1Certificates": true,
|
||||
"MinimumCertificateKeySize": 2048,
|
||||
"PkiRootPath": null,
|
||||
"CertificateSubject": null
|
||||
},
|
||||
"Redundancy": {
|
||||
"Enabled": false,
|
||||
"Mode": "Warm",
|
||||
"Role": "Primary",
|
||||
"ServerUris": [],
|
||||
"ServiceLevelBase": 200
|
||||
"OpcUaServer": {
|
||||
"EndpointUrl": "opc.tcp://0.0.0.0:4840/OtOpcUa",
|
||||
"ApplicationUri": "urn:node-dev-a:OtOpcUa",
|
||||
"SecurityProfile": "None",
|
||||
"AutoAcceptUntrustedClientCertificates": true,
|
||||
"Ldap": { "Enabled": false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### OtOpcUa Admin — `src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json`
|
||||
|
||||
| Section | Purpose |
|
||||
|---|---|
|
||||
| `ConnectionStrings:ConfigDb` | SQL connection string — must point at the same Config DB every Server reaches. |
|
||||
| `Authentication:Ldap` | LDAP bind for the Admin login form (same options shape as the Server's `OpcUaServer:Ldap`). |
|
||||
| `CertTrust` | `CertTrustOptions` — file-system path under the Server's `PkiStoreRoot` so the Admin Certificates page can promote rejected client certs. |
|
||||
| `Metrics:Prometheus:Enabled` | Toggles the `/metrics` scrape endpoint (default true). |
|
||||
| `Serilog` | Logging. |
|
||||
|
||||
### Galaxy.Host
|
||||
|
||||
Environment-variable driven (`OTOPCUA_GALAXY_PIPE`, `OTOPCUA_ALLOWED_SID`, `OTOPCUA_GALAXY_SECRET`, `OTOPCUA_GALAXY_BACKEND`, `OTOPCUA_GALAXY_ZB_CONN`, `OTOPCUA_HISTORIAN_*`). No `appsettings.json` — the supervisor owns the launch environment. See [`ServiceHosting.md`](ServiceHosting.md#galaxyhost-process).
|
||||
|
||||
### Environment overrides
|
||||
|
||||
Standard .NET config layering applies: `appsettings.{Environment}.json`, then environment variables with `Section__Property` naming. `DOTNET_ENVIRONMENT` (or `ASPNETCORE_ENVIRONMENT` for Admin) selects the overlay.
|
||||
|
||||
---
|
||||
|
||||
## Authoritative configuration (Config DB)
|
||||
|
||||
The Config DB is the single source of truth for every setting that a v1 deployment used to carry in `appsettings.json` as driver-specific state. `OtOpcUaConfigDbContext` (`src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs`) is the EF Core context used by both the Admin writer and every Server reader.
|
||||
|
||||
### Top-level sections operators touch
|
||||
|
||||
| Concept | Entity | Admin UI surface | Purpose |
|
||||
|---|---|---|---|
|
||||
| Cluster | `ServerCluster` | Clusters pages | Fleet unit; owns nodes, generations, UNS, ACLs. |
|
||||
| Cluster node | `ClusterNode` + `ClusterNodeCredential` | RedundancyTab, Hosts page | Per-node identity, `RedundancyRole`, `ServiceLevelBase`, ApplicationUri, service-account credentials. |
|
||||
| Generation | `ConfigGeneration` + `ClusterNodeGenerationState` | Generations / DiffViewer | Append-only; draft → publish workflow (`sp_PublishGeneration`). |
|
||||
| Namespace | `Namespace` | Namespaces tab | Per-cluster OPC UA namespace; `Kind` = Equipment / SystemPlatform / Simulated. |
|
||||
| Driver instance | `DriverInstance` | Drivers tab | Configured driver (Modbus, S7, OpcUaClient, Galaxy, …) + `DriverConfig` JSON + resilience profile. |
|
||||
| Device | `Device` | Under each driver instance | Per-host settings inside a driver instance (IP, port, unit-id…). |
|
||||
| UNS hierarchy | `UnsArea` + `UnsLine` | UnsTab (drag/drop) | L3 / L4 of the unified namespace. |
|
||||
| Equipment | `Equipment` | Equipment pages, CSV import | L5; carries `MachineCode`, `ZTag`, `SAPID`, `EquipmentUuid`, reservation-backed external ids. |
|
||||
| Tag | `Tag` | Under each equipment | Driver-specific tag address + `SecurityClassification` + poll-group assignment. |
|
||||
| Poll group | `PollGroup` | Driver-scoped | Poll cadence buckets; `PollGroupEngine` in Core.Abstractions uses this at runtime. |
|
||||
| ACL | `NodeAcl` | AclsTab + Probe dialog | Per-level permission grants, additive only. See [`security.md`](security.md#data-plane-authorization). |
|
||||
| Role grant | `LdapGroupRoleMapping` | RoleGrants page | Maps LDAP groups → Admin roles (`ConfigViewer` / `ConfigEditor` / `FleetAdmin`). |
|
||||
| External id reservation | `ExternalIdReservation` | Reservations page | Reservation-backed `ZTag` and `SAPID` uniqueness. |
|
||||
| Equipment import batch | `EquipmentImportBatch` | CSV import flow | Staged bulk-add with validation preview. |
|
||||
| Audit log | `ConfigAuditLog` | Audit page | Append-only record of every publish, rollback, credential rotation, role-grant change. |
|
||||
|
||||
### Draft → publish generation model
|
||||
|
||||
All edits go into a **draft** generation scoped to one cluster. `DraftValidationService` checks invariants (same-cluster FKs, reservation collisions, UNS path consistency, ACL scope validity). When the operator clicks Publish, `sp_PublishGeneration` atomically promotes the draft, records the audit event, and causes every `RedundancyCoordinator.RefreshAsync` in the affected cluster to pick up the new topology + ACL set. The Admin UI `DiffViewer` shows exactly what's changing before publish.
|
||||
|
||||
Old generations are retained; rollback is "publish older generation as new". `ConfigAuditLog` makes every change auditable by principal + timestamp.
|
||||
|
||||
### Offline cache
|
||||
|
||||
Each Server process caches the last-seen published generation in `Node:LocalCachePath` via LiteDB (`LiteDbConfigCache` in `src/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/`). The cache lets a node start without the central DB reachable; once the DB comes back, `NodeBootstrap` syncs to the current generation.
|
||||
|
||||
### Full schema reference
|
||||
|
||||
For table columns, indexes, stored procedures, the publish-transaction semantics, and the SQL authorization model (per-node SQL principals + `SESSION_CONTEXT` cluster binding), see [`docs/v2/config-db-schema.md`](v2/config-db-schema.md).
|
||||
|
||||
### Admin UI flow
|
||||
|
||||
For the draft editor, DiffViewer, CSV import, IdentificationFields, RedundancyTab, AclsTab + Probe-this-permission, RoleGrants, and the SignalR real-time surface, see [`docs/v2/admin-ui.md`](v2/admin-ui.md).
|
||||
|
||||
---
|
||||
|
||||
## Where did v1 appsettings sections go?
|
||||
|
||||
Quick index for operators coming from v1 LmxOpcUa:
|
||||
|
||||
| v1 appsettings section | v2 home |
|
||||
|---|---|
|
||||
| `OpcUa.Port` / `BindAddress` / `EndpointPath` / `ServerName` | Bootstrap `OpcUaServer:EndpointUrl` + `ApplicationName`. |
|
||||
| `OpcUa.ApplicationUri` | Config DB `ClusterNode.ApplicationUri`. |
|
||||
| `OpcUa.MaxSessions` / `SessionTimeoutMinutes` | Bootstrap `OpcUaServer:*` (if exposed) or stack defaults. |
|
||||
| `OpcUa.AlarmTrackingEnabled` / `AlarmFilter` | Per driver instance in Config DB (alarm surface is capability-driven per `IAlarmSource`). |
|
||||
| `MxAccess.*` | Galaxy driver instance `DriverConfig` JSON + Galaxy.Host env vars (see [`ServiceHosting.md`](ServiceHosting.md#galaxyhost-process)). |
|
||||
| `GalaxyRepository.*` | Galaxy driver instance `DriverConfig` JSON + `OTOPCUA_GALAXY_ZB_CONN` env var. |
|
||||
| `Dashboard.*` | Retired — Admin UI replaces the dashboard. See [`StatusDashboard.md`](StatusDashboard.md). |
|
||||
| `Historian.*` | Galaxy driver instance `DriverConfig` JSON + `OTOPCUA_HISTORIAN_*` env vars. |
|
||||
| `Authentication.Ldap.*` | Bootstrap `OpcUaServer:Ldap` (same shape) + Admin `Authentication:Ldap` for the UI login. |
|
||||
| `Security.*` | Bootstrap `OpcUaServer:SecurityProfile` + `PkiStoreRoot` + `AutoAcceptUntrustedClientCertificates`. |
|
||||
| `Redundancy.*` | Config DB `ClusterNode.RedundancyRole` + `ServiceLevelBase`. |
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
- **Bootstrap**: the process fails fast on missing required keys in `Program.cs` (e.g. `Node:NodeId`, `Node:ClusterId`, `Node:ConfigDbConnectionString` all throw `InvalidOperationException` if unset).
|
||||
- **Authoritative**: `DraftValidationService` runs on every save; `sp_ValidateDraft` runs as part of `sp_PublishGeneration` so an invalid draft cannot reach any node.
|
||||
|
||||
@@ -1,84 +1,65 @@
|
||||
# Data Type Mapping
|
||||
|
||||
`MxDataTypeMapper` and `SecurityClassificationMapper` translate Galaxy attribute metadata into OPC UA variable node properties. These mappings determine how Galaxy runtime values are represented to OPC UA clients and whether clients can write to them.
|
||||
Data-type mapping is driver-defined. Each driver translates its native attribute metadata into two driver-agnostic enums from `Core.Abstractions` — `DriverDataType` (`src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs`) and `SecurityClassification` (`src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/SecurityClassification.cs`) — and populates the `DriverAttributeInfo` record it hands to `IAddressSpaceBuilder.Variable(...)`. Core doesn't interpret the native types; it trusts the driver's translation.
|
||||
|
||||
## mx_data_type to OPC UA Type Mapping
|
||||
## DriverDataType → OPC UA built-in type
|
||||
|
||||
Each Galaxy attribute carries an `mx_data_type` integer that identifies its data type. `MxDataTypeMapper.MapToOpcUaDataType` maps these to OPC UA built-in type NodeIds:
|
||||
`DriverNodeManager.MapDataType` (`src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs`) is the single translation table for every driver:
|
||||
|
||||
| mx_data_type | Galaxy type | OPC UA type | NodeId | CLR type |
|
||||
|:---:|-------------|-------------|:------:|----------|
|
||||
| 1 | Boolean | Boolean | i=1 | `bool` |
|
||||
| 2 | Integer | Int32 | i=6 | `int` |
|
||||
| 3 | Float | Float | i=10 | `float` |
|
||||
| 4 | Double | Double | i=11 | `double` |
|
||||
| 5 | String | String | i=12 | `string` |
|
||||
| 6 | Time | DateTime | i=13 | `DateTime` |
|
||||
| 7 | ElapsedTime | Double | i=11 | `double` |
|
||||
| 8 | Reference | String | i=12 | `string` |
|
||||
| 13 | Enumeration | Int32 | i=6 | `int` |
|
||||
| 14 | Custom | String | i=12 | `string` |
|
||||
| 15 | InternationalizedString | LocalizedText | i=21 | `string` |
|
||||
| 16 | Custom | String | i=12 | `string` |
|
||||
| other | Unknown | String | i=12 | `string` |
|
||||
| DriverDataType | OPC UA NodeId |
|
||||
|---|---|
|
||||
| `Boolean` | `DataTypeIds.Boolean` (i=1) |
|
||||
| `Int32` | `DataTypeIds.Int32` (i=6) |
|
||||
| `Float32` | `DataTypeIds.Float` (i=10) |
|
||||
| `Float64` | `DataTypeIds.Double` (i=11) |
|
||||
| `String` | `DataTypeIds.String` (i=12) |
|
||||
| `DateTime` | `DataTypeIds.DateTime` (i=13) |
|
||||
| anything else | `DataTypeIds.BaseDataType` |
|
||||
|
||||
Unknown types default to String. This is a safe fallback because MXAccess delivers values as COM `VARIANT` objects, and string serialization preserves any value that does not have a direct OPC UA counterpart.
|
||||
The enum also carries `Int16 / Int64 / UInt16 / UInt32 / UInt64 / Reference` members for drivers that need them; the mapping table is extended as those types surface in actual drivers. `Reference` is the Galaxy-style attribute reference — it's encoded as an OPC UA `String` on the wire.
|
||||
|
||||
### Why ElapsedTime maps to Double
|
||||
## Per-driver mappers
|
||||
|
||||
Galaxy `ElapsedTime` (mx_data_type 7) represents a duration/timespan. OPC UA has no native `TimeSpan` type. The OPC UA specification defines a `Duration` type alias (NodeId i=290) that is semantically a `Double` representing milliseconds, but the simpler approach is to map directly to `Double` (i=11) representing seconds. This avoids ambiguity about whether the value is in seconds or milliseconds and matches how the Galaxy runtime exposes elapsed time values through MXAccess.
|
||||
Each driver owns its native → `DriverDataType` translation:
|
||||
|
||||
## Array Handling
|
||||
- **Galaxy Proxy** — `GalaxyProxyDriver.MapDataType(int mxDataType)` and `MapSecurity(int mxSec)` (inline in `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs`). The Galaxy `mx_data_type` integer is sent across the Host↔Proxy pipe and mapped on the Proxy side. Galaxy's full classic 16-entry table (Boolean / Integer / Float / Double / String / Time / ElapsedTime / Reference / Enumeration / Custom / InternationalizedString) is preserved but compressed into the seven-entry `DriverDataType` enum — `ElapsedTime` → `Float64`, `InternationalizedString` → `String`, `Reference` → `Reference`, enumerations → `Int32`.
|
||||
- **AB CIP** — `src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDataType.cs` maps CIP tag type codes.
|
||||
- **Modbus** — `src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs` maps register shapes (16-bit signed, 16-bit unsigned, 32-bit float, etc.) including the DirectLogic quirk table in `DirectLogicAddress.cs`.
|
||||
- **S7 / AB Legacy / TwinCAT / FOCAS / OPC UA Client** — each has its own inline mapper or `*DataType.cs` file per the same pattern.
|
||||
|
||||
Galaxy attributes with `is_array = 1` in the repository are exposed as one-dimensional OPC UA array variables.
|
||||
The driver's mapping is authoritative — when a field type is ambiguous (a `LREAL` that could be bit-reinterpreted, a BCD counter, a string of a particular encoding), the driver decides the exposed OPC UA shape.
|
||||
|
||||
### ValueRank
|
||||
## Array handling
|
||||
|
||||
The `ValueRank` property on the OPC UA variable node indicates the array dimensionality:
|
||||
`DriverAttributeInfo.IsArray = true` flips `ValueRank = OneDimension` on the generated `BaseDataVariableState`; scalars stay at `ValueRank.Scalar`. `DriverAttributeInfo.ArrayDim` carries the declared length. Writing element-by-element (OPC UA `IndexRange`) is a driver-level decision — see `docs/ReadWriteOperations.md`.
|
||||
|
||||
| `is_array` | ValueRank | Constant |
|
||||
|:---:|:---------:|----------|
|
||||
| 0 | -1 | `ValueRanks.Scalar` |
|
||||
| 1 | 1 | `ValueRanks.OneDimension` |
|
||||
## SecurityClassification — metadata, not ACL
|
||||
|
||||
### ArrayDimensions
|
||||
`SecurityClassification` is driver-reported metadata only. Drivers never enforce write permissions themselves — the classification flows into the Server project where `WriteAuthzPolicy.IsAllowed(classification, userRoles)` (`src/ZB.MOM.WW.OtOpcUa.Server/Security/WriteAuthzPolicy.cs`) gates the write against the session's LDAP-derived roles, and (Phase 6.2) the `AuthorizationGate` + permission trie apply on top. This is the "ACL at server layer" invariant recorded in `feedback_acl_at_server_layer.md`.
|
||||
|
||||
When `ValueRank = 1`, the `ArrayDimensions` property is set to a single-element `ReadOnlyList<uint>` containing the declared array length from `array_dimension`:
|
||||
The classification values mirror the v1 Galaxy model so existing Galaxy galaxies keep their published semantics:
|
||||
|
||||
```csharp
|
||||
if (attr.IsArray && attr.ArrayDimension.HasValue)
|
||||
{
|
||||
variable.ArrayDimensions = new ReadOnlyList<uint>(
|
||||
new List<uint> { (uint)attr.ArrayDimension.Value });
|
||||
}
|
||||
```
|
||||
| SecurityClassification | Required role | Write-from-OPC-UA |
|
||||
|---|---|---|
|
||||
| `FreeAccess` | — | yes (even anonymous) |
|
||||
| `Operate` | `WriteOperate` | yes |
|
||||
| `Tune` | `WriteTune` | yes |
|
||||
| `Configure` | `WriteConfigure` | yes |
|
||||
| `SecuredWrite` | `WriteOperate` | yes |
|
||||
| `VerifiedWrite` | `WriteConfigure` | yes |
|
||||
| `ViewOnly` | — | no |
|
||||
|
||||
The `array_dimension` value is extracted from the `mx_value` binary column in the Galaxy database (bytes 13-16, little-endian int32).
|
||||
Drivers whose backend has no notion of classification (Modbus, most PLCs) default every tag to `FreeAccess` or `Operate`; drivers whose backend does carry the notion (Galaxy, OPC UA Client relaying `UserAccessLevel`) translate it directly.
|
||||
|
||||
### NodeId for array variables
|
||||
## Historization
|
||||
|
||||
Array variables use a NodeId without the `[]` suffix. The `full_tag_reference` stored internally for MXAccess addressing retains the `[]` (e.g., `MESReceiver_001.MoveInPartNumbers[]`), but the OPC UA NodeId strips it to `ns=1;s=MESReceiver_001.MoveInPartNumbers`.
|
||||
|
||||
## Security Classification to AccessLevel Mapping
|
||||
|
||||
Galaxy attributes carry a `security_classification` value that controls write permissions. `SecurityClassificationMapper.IsWritable` determines the OPC UA `AccessLevel`:
|
||||
|
||||
| security_classification | Galaxy level | OPC UA AccessLevel | Writable |
|
||||
|:---:|--------------|-------------------|:--------:|
|
||||
| 0 | FreeAccess | CurrentReadOrWrite | Yes |
|
||||
| 1 | Operate | CurrentReadOrWrite | Yes |
|
||||
| 2 | SecuredWrite | CurrentRead | No |
|
||||
| 3 | VerifiedWrite | CurrentRead | No |
|
||||
| 4 | Tune | CurrentReadOrWrite | Yes |
|
||||
| 5 | Configure | CurrentReadOrWrite | Yes |
|
||||
| 6 | ViewOnly | CurrentRead | No |
|
||||
|
||||
Most attributes default to Operate (1). The mapper treats SecuredWrite, VerifiedWrite, and ViewOnly as read-only because the OPC UA server does not implement the Galaxy's multi-level authentication model. Allowing writes to SecuredWrite or VerifiedWrite attributes without proper verification would bypass Galaxy security.
|
||||
|
||||
For historized attributes, `AccessLevels.HistoryRead` is added to the access level via bitwise OR, enabling OPC UA history read requests when an `IHistorianDataSource` is configured via the runtime-loaded historian plugin.
|
||||
`DriverAttributeInfo.IsHistorized = true` flips `AccessLevel.HistoryRead` and `Historizing = true` on the variable. The driver must then implement `IHistoryProvider` for HistoryRead service calls to succeed; otherwise the node manager surfaces `BadHistoryOperationUnsupported` per request.
|
||||
|
||||
## Key source files
|
||||
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/Domain/MxDataTypeMapper.cs` -- Type and CLR mapping
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/Domain/SecurityClassificationMapper.cs` -- Write access mapping
|
||||
- `gr/data_type_mapping.md` -- Reference documentation for the full mapping table
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs` — driver-agnostic type enum
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/SecurityClassification.cs` — write-authz tier metadata
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs` — per-attribute descriptor
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs` — `MapDataType` translation
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Server/Security/WriteAuthzPolicy.cs` — classification-to-role policy
|
||||
- Per-driver mappers in each `Driver.*` project
|
||||
|
||||
@@ -1,228 +1,109 @@
|
||||
# Historical Data Access
|
||||
|
||||
`LmxNodeManager` exposes OPC UA historical data access (HDA) through an abstract `IHistorianDataSource` interface (`Historian/IHistorianDataSource.cs`). The Wonderware Historian implementation lives in a separate assembly, `ZB.MOM.WW.OtOpcUa.Historian.Aveva`, which is loaded at runtime only when `Historian.Enabled=true`. This keeps the `aahClientManaged` SDK out of the core Host so deployments that do not need history do not need the SDK installed.
|
||||
OPC UA HistoryRead is a **per-driver optional capability** in OtOpcUa. The Core dispatches HistoryRead service calls to the owning driver through the `IHistoryProvider` capability interface (`src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs`). Drivers that don't implement the interface return `BadHistoryOperationUnsupported` for every history call on their nodes; that is the expected behavior for protocol drivers (Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS) whose wire protocols carry no time-series data.
|
||||
|
||||
## Plugin Architecture
|
||||
Historian integration is no longer a separate bolt-on assembly, as it was in v1 (`ZB.MOM.WW.LmxOpcUa.Historian.Aveva` plugin). It is now one optional capability any driver can implement. The first implementation is the Galaxy driver's Wonderware Historian integration; OPC UA Client forwards HistoryRead to the upstream server. Every other driver leaves the capability unimplemented and the Core short-circuits history calls on nodes that belong to those drivers.
|
||||
|
||||
The historian surface is split across two assemblies:
|
||||
## `IHistoryProvider`
|
||||
|
||||
- **`ZB.MOM.WW.OtOpcUa.Host`** (core) owns only OPC UA / BCL types:
|
||||
- `IHistorianDataSource` -- the interface `LmxNodeManager` depends on
|
||||
- `HistorianEventDto` -- SDK-free representation of a historian event record
|
||||
- `HistorianAggregateMap` -- maps OPC UA aggregate NodeIds to AnalogSummary column names
|
||||
- `HistorianPluginLoader` -- loads the plugin via `Assembly.LoadFrom` at startup
|
||||
- `HistoryContinuationPointManager` -- paginates HistoryRead results
|
||||
- **`ZB.MOM.WW.OtOpcUa.Historian.Aveva`** (plugin) owns everything SDK-bound:
|
||||
- `HistorianDataSource` -- implements `IHistorianDataSource`, wraps `aahClientManaged`
|
||||
- `IHistorianConnectionFactory` / `SdkHistorianConnectionFactory` -- opens and polls `ArchestrA.HistorianAccess` connections
|
||||
- `AvevaHistorianPluginEntry.Create(HistorianConfiguration)` -- the static factory invoked by the loader
|
||||
Four methods, mapping onto the four OPC UA HistoryRead service variants:
|
||||
|
||||
The plugin assembly and its SDK dependencies (`aahClientManaged.dll`, `aahClient.dll`, `aahClientCommon.dll`, `Historian.CBE.dll`, `Historian.DPAPI.dll`, `ArchestrA.CloudHistorian.Contract.dll`) deploy to a `Historian/` subfolder next to `ZB.MOM.WW.OtOpcUa.Host.exe`. See [Service Hosting](ServiceHosting.md#required-runtime-assemblies) for the full layout and deployment matrix.
|
||||
| Method | OPC UA service | Notes |
|
||||
|--------|----------------|-------|
|
||||
| `ReadRawAsync` | HistoryReadRawModified (raw subset) | Returns `HistoryReadResult { Samples, ContinuationPoint? }`. The Core handles `ContinuationPoint` pagination. |
|
||||
| `ReadProcessedAsync` | HistoryReadProcessed | Takes a `HistoryAggregateType` (Average / Minimum / Maximum / Total / Count) and a bucket `interval`. Drivers that can't express an aggregate throw `NotSupportedException`; the Core translates that into `BadAggregateNotSupported`. |
|
||||
| `ReadAtTimeAsync` | HistoryReadAtTime | Default implementation throws `NotSupportedException` — drivers without interpolation / prior-boundary support leave the default. |
|
||||
| `ReadEventsAsync` | HistoryReadEvents | Historical alarm/event rows, distinct from the live `IAlarmSource` stream. Default throws; only drivers with an event historian (Galaxy's A&E log) override. |
|
||||
|
||||
## Plugin Loading
|
||||
Supporting DTOs live alongside the interface in `Core.Abstractions`:
|
||||
|
||||
When the service starts with `Historian.Enabled=true`, `OpcUaService` calls `HistorianPluginLoader.TryLoad(config)`. The loader:
|
||||
- `HistoryReadResult(IReadOnlyList<DataValueSnapshot> Samples, byte[]? ContinuationPoint)`
|
||||
- `HistoryAggregateType` — enum `{ Average, Minimum, Maximum, Total, Count }`
|
||||
- `HistoricalEvent(EventId, SourceName?, EventTimeUtc, ReceivedTimeUtc, Message?, Severity)`
|
||||
- `HistoricalEventsResult(IReadOnlyList<HistoricalEvent> Events, byte[]? ContinuationPoint)`
|
||||
|
||||
1. Probes `AppDomain.CurrentDomain.BaseDirectory\Historian\ZB.MOM.WW.OtOpcUa.Historian.Aveva.dll`.
|
||||
2. Installs a one-shot `AppDomain.AssemblyResolve` handler that redirects any `aahClientManaged`/`aahClientCommon`/`Historian.*` lookups to the same subfolder, so the CLR can resolve SDK dependencies when the plugin first JITs.
|
||||
3. Calls the plugin's `AvevaHistorianPluginEntry.Create(HistorianConfiguration)` via reflection and returns the resulting `IHistorianDataSource`.
|
||||
4. On any failure (plugin missing, entry type not found, SDK assembly unresolvable, bad image), logs a warning with the expected plugin path and returns `null`. The server starts normally and `LmxNodeManager` returns `BadHistoryOperationUnsupported` for every history call.
|
||||
## Dispatch through `CapabilityInvoker`
|
||||
|
||||
## Wonderware Historian SDK
|
||||
All four HistoryRead surfaces are wrapped by `CapabilityInvoker` (`Core/Resilience/CapabilityInvoker.cs`) with `DriverCapability.HistoryRead`. The Polly pipeline keyed on `(DriverInstanceId, HostName, DriverCapability.HistoryRead)` provides timeout, circuit-breaker, and bulkhead defaults per the driver's stability tier (see [docs/v2/driver-stability.md](v2/driver-stability.md)).
|
||||
|
||||
The plugin uses the AVEVA Historian managed SDK (`aahClientManaged.dll`) to query historical data. The SDK provides a cursor-based query API through `ArchestrA.HistorianAccess`, replacing direct SQL queries against the Historian Runtime database. Two query types are used:
|
||||
The dispatch point is `DriverNodeManager` in `ZB.MOM.WW.OtOpcUa.Server`. When the OPC UA stack calls `HistoryRead`, the node manager:
|
||||
|
||||
- **`HistoryQuery`** -- Raw historical samples with timestamp, value (numeric or string), and OPC quality.
|
||||
- **`AnalogSummaryQuery`** -- Pre-computed aggregates with properties for Average, Minimum, Maximum, ValueCount, First, Last, StdDev, and more.
|
||||
1. Resolves the target `NodeHandle` to a `(DriverInstanceId, fullReference)` pair.
|
||||
2. Checks the owning driver's `DriverTypeMetadata` to see if the type may advertise history at all (fast reject for types that never implement `IHistoryProvider`).
|
||||
3. If the driver instance implements `IHistoryProvider`, wraps the `ReadRawAsync` / `ReadProcessedAsync` / `ReadAtTimeAsync` / `ReadEventsAsync` call in `CapabilityInvoker.InvokeAsync(... DriverCapability.HistoryRead ...)`.
|
||||
4. Translates the `HistoryReadResult` into an OPC UA `HistoryData` + `ExtensionObject`.
|
||||
5. Manages the continuation point via `HistoryContinuationPointManager` so clients can page through large result sets.
|
||||
|
||||
The SDK DLLs are located in `lib/` and originate from `C:\Program Files (x86)\Wonderware\Historian\`. Only the plugin project (`src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/`) references them at build time; the core Host project does not.
|
||||
Driver-level history code never sees the continuation-point protocol or the OPC UA stack types — those stay in the Core.
|
||||
|
||||
## Configuration
|
||||
## Driver coverage
|
||||
|
||||
`HistorianConfiguration` controls the SDK connection:
|
||||
| Driver | Implements `IHistoryProvider`? | Source |
|
||||
|--------|:------------------------------:|--------|
|
||||
| Galaxy | Yes — raw, processed, at-time, events | `aahClientManaged` SDK (Wonderware Historian) on the Host side, forwarded through the Proxy's IPC |
|
||||
| OPC UA Client | Yes — raw, processed, at-time, events (forwarded to upstream) | `Opc.Ua.Client.Session.HistoryRead` against the remote server |
|
||||
| Modbus | No | Wire protocol has no time-series concept |
|
||||
| Siemens S7 | No | S7comm has no time-series concept |
|
||||
| AB CIP | No | CIP has no time-series concept |
|
||||
| AB Legacy | No | PCCC has no time-series concept |
|
||||
| TwinCAT | No | ADS symbol reads are point-in-time; archiving is an external concern |
|
||||
| FOCAS | No | Default — FOCAS has no general-purpose historian API |
|
||||
|
||||
```csharp
|
||||
public class HistorianConfiguration
|
||||
{
|
||||
public bool Enabled { get; set; } = false;
|
||||
public string ServerName { get; set; } = "localhost";
|
||||
public List<string> ServerNames { get; set; } = new();
|
||||
public int FailureCooldownSeconds { get; set; } = 60;
|
||||
public bool IntegratedSecurity { get; set; } = true;
|
||||
public string? UserName { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public int Port { get; set; } = 32568;
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
public int MaxValuesPerRead { get; set; } = 10000;
|
||||
public int RequestTimeoutSeconds { get; set; } = 60;
|
||||
}
|
||||
```
|
||||
## Galaxy — Wonderware Historian (`aahClientManaged`)
|
||||
|
||||
When `Enabled` is `false`, `HistorianPluginLoader.TryLoad` is not called, no plugin is loaded, and the node manager returns `BadHistoryOperationUnsupported` for history read requests. When `Enabled` is `true` but the plugin cannot be loaded (missing `Historian/` subfolder, SDK assembly resolve failure, etc.), the server still starts and returns the same `BadHistoryOperationUnsupported` status with a warning in the log.
|
||||
The Galaxy driver's `IHistoryProvider` implementation lives on the Host side (`.NET 4.8 x86`) in `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Historian/`. The Proxy's `GalaxyProxyDriver.ReadRawAsync` / `ReadProcessedAsync` / `ReadAtTimeAsync` / `ReadEventsAsync` each serializes a `HistoryRead*Request` and awaits the matching `HistoryRead*Response` over the named pipe (see [drivers/Galaxy.md](drivers/Galaxy.md#ipc-transport)).
|
||||
|
||||
### Connection Properties
|
||||
Host-side, `HistorianDataSource` uses the AVEVA Historian managed SDK (`aahClientManaged.dll`) to query historical data via a cursor-based API through `ArchestrA.HistorianAccess`:
|
||||
|
||||
| Property | Default | Description |
|
||||
|---|---|---|
|
||||
| `ServerName` | `localhost` | Single Historian server hostname used when `ServerNames` is empty. Preserved for backward compatibility with pre-cluster deployments |
|
||||
| `ServerNames` | `[]` | Ordered list of Historian cluster nodes. When non-empty, supersedes `ServerName` and enables read-only cluster failover (see [Cluster Failover](#read-only-cluster-failover)) |
|
||||
| `FailureCooldownSeconds` | `60` | How long a failed cluster node is skipped before being re-tried. Zero means no cooldown (retry on every request) |
|
||||
| `IntegratedSecurity` | `true` | Use Windows authentication |
|
||||
| `UserName` | `null` | Username when `IntegratedSecurity` is false |
|
||||
| `Password` | `null` | Password when `IntegratedSecurity` is false |
|
||||
| `Port` | `32568` | Historian TCP port |
|
||||
| `CommandTimeoutSeconds` | `30` | SDK packet timeout in seconds (inner async bound) |
|
||||
| `RequestTimeoutSeconds` | `60` | Outer safety timeout applied to sync-over-async history reads on the OPC UA stack thread. Backstop for `CommandTimeoutSeconds`; a timed-out read returns `BadTimeout`. Should be greater than `CommandTimeoutSeconds`. Stability review 2026-04-13 Finding 3 |
|
||||
| `MaxValuesPerRead` | `10000` | Maximum values per history read request |
|
||||
- **`HistoryQuery`** — raw historical samples (timestamp, value, OPC quality)
|
||||
- **`AnalogSummaryQuery`** — pre-computed aggregates (Average, Minimum, Maximum, ValueCount, First, Last, StdDev)
|
||||
|
||||
## Connection Lifecycle
|
||||
The SDK DLLs are pulled into the Galaxy.Host project at build time; the Server and every other driver project remain SDK-free.
|
||||
|
||||
`HistorianDataSource` (in the plugin assembly) maintains a persistent connection to the Historian server via `ArchestrA.HistorianAccess`:
|
||||
> **Gap / status note.** The raw SDK wrapper (`HistorianDataSource`, `HistorianClusterEndpointPicker`, `HistorianHealthSnapshot`, etc.) has been ported from the v1 `ZB.MOM.WW.LmxOpcUa.Historian.Aveva` plugin into `Driver.Galaxy.Host/Backend/Historian/`. The **IPC wire-up** — `HistoryReadRequest` / `HistoryReadResponse` message kinds, Proxy-side `ReadRawAsync` / `ReadProcessedAsync` / `ReadAtTimeAsync` / `ReadEventsAsync` forwarding — is in place on `GalaxyProxyDriver`. What remains to close on a given branch is Host-side **mapping of `HistoryAggregateType` onto the `AnalogSummaryQuery` column names** (done in `GalaxyProxyDriver.MapAggregateToColumn`; the Host side must mirror it) and the **end-to-end integration test** that was held by the v1 plugin suite. Until those land on a given driver branch, history calls against Galaxy may surface `GalaxyIpcException { Code = "not-implemented" }` or backend-specific errors rather than populated `HistoryReadResult`s. Track the remaining work against the Phase 2 Galaxy out-of-process gate in `docs/v2/plan.md`.
|
||||
|
||||
1. **Lazy connect** -- The connection is established on the first query via `EnsureConnected()`. When a cluster is configured, the data source iterates `HistorianClusterEndpointPicker.GetHealthyNodes()` in order and returns the first node that successfully connects.
|
||||
2. **Connection reuse** -- Subsequent queries reuse the same connection. The active node is tracked in `_activeProcessNode` / `_activeEventNode` and surfaced on the dashboard.
|
||||
3. **Auto-reconnect** -- On connection failure, the connection is disposed, the active node is marked failed in the picker, and the next query re-enters the picker loop to try the next eligible candidate.
|
||||
4. **Clean shutdown** -- `Dispose()` closes the connection when the service stops.
|
||||
### Aggregate function mapping
|
||||
|
||||
The connection is opened with `ReadOnly = true` and `ConnectionType = Process`. The event (alarm history) path uses a separate connection with `ConnectionType = Event`, but both silos share the same cluster picker so a node that fails on one silo is immediately skipped on the other.
|
||||
`GalaxyProxyDriver.MapAggregateToColumn` (Proxy-side) translates the OPC UA Part 13 standard aggregate enum onto `AnalogSummaryQuery` column names consumed by `HistorianDataSource.ReadAggregateAsync`:
|
||||
|
||||
## Read-Only Cluster Failover
|
||||
| `HistoryAggregateType` | Result Property |
|
||||
|------------------------|-----------------|
|
||||
| `Average` | `Average` |
|
||||
| `Minimum` | `Minimum` |
|
||||
| `Maximum` | `Maximum` |
|
||||
| `Count` | `ValueCount` |
|
||||
|
||||
When `HistorianConfiguration.ServerNames` is non-empty, the plugin picks from an ordered list of cluster nodes instead of a single `ServerName`. Each connection attempt tries candidates in configuration order until one succeeds. Failed nodes are placed into a timed cooldown and re-admitted when the cooldown elapses.
|
||||
`HistoryAggregateType.Total` is **not supported** by Wonderware `AnalogSummary` and raises `NotSupportedException`, which the Core translates to `BadAggregateNotSupported`. Additional OPC UA aggregates (`Start`, `End`, `StandardDeviationPopulation`) sit on the Historian columns `First`, `Last`, `StdDev` and can be exposed by extending the enum + mapping together.
|
||||
|
||||
### HistorianClusterEndpointPicker
|
||||
### Read-only cluster failover
|
||||
|
||||
The picker (in the plugin assembly, internal) is pure logic with no SDK dependency — all cluster behavior is unit-testable with a fake clock and scripted factory. Key characteristics:
|
||||
`HistorianConfiguration.ServerNames` accepts an ordered list of cluster nodes. `HistorianClusterEndpointPicker` iterates the list in configuration order, marks failed nodes with a `FailureCooldownSeconds` window, and re-admits them when the cooldown elapses. One picker instance is shared by the process-values connection and the event-history connection (two SDK silos), so a node failure on one silo immediately benches it for the other. `FailureCooldownSeconds = 0` disables the cooldown — the SDK's own retry semantics are the sole gate.
|
||||
|
||||
- **Ordered iteration**: nodes are tried in the exact order they appear in `ServerNames`. Operators can express a preference ("primary first, fallback second") by ordering the list.
|
||||
- **Per-node cooldown**: `MarkFailed(node, error)` starts a `FailureCooldownSeconds` window during which the node is skipped from `GetHealthyNodes()`. `MarkHealthy(node)` clears the window immediately (used on successful connect).
|
||||
- **Automatic re-admission**: when a node's cooldown elapses, the next call to `GetHealthyNodes()` includes it automatically — no background probe, no manual reset. The cumulative `FailureCount` and `LastError` are retained for operator diagnostics.
|
||||
- **Thread-safe**: a single lock guards the per-node state. Operations are microsecond-scale so contention is a non-issue.
|
||||
- **Shared across silos**: one picker instance is shared by the process-values connection and the event-history connection, so a node failure on one path immediately benches it for the other.
|
||||
- **Zero cooldown mode**: `FailureCooldownSeconds = 0` disables the cooldown entirely — the node is never benched. Useful for tests or for operators who want the SDK's own retry semantics to be the sole gate.
|
||||
Host-side cluster health is surfaced via `HistorianHealthSnapshot { NodeCount, HealthyNodeCount, ActiveProcessNode, ActiveEventNode, Nodes }` and forwarded to the Proxy so the Admin UI Historian panel can render a per-node table. `HealthCheckService` flips overall service health to `Degraded` when `HealthyNodeCount < NodeCount`.
|
||||
|
||||
### Connection attempt flow
|
||||
### Runtime health counters
|
||||
|
||||
`HistorianDataSource.ConnectToAnyHealthyNode(HistorianConnectionType)` performs the actual iteration:
|
||||
`HistorianDataSource` maintains per-read counters — `TotalQueries`, `TotalSuccesses`, `TotalFailures`, `ConsecutiveFailures`, `LastSuccessTime`, `LastFailureTime`, `LastError`, `ProcessConnectionOpen`, `EventConnectionOpen` — so the dashboard can distinguish "backend loaded but never queried" from "backend loaded and queries are failing". `LastError` is prefixed with the read path (`raw:`, `aggregate:`, `at-time:`, `events:`) so operators can tell which silo is broken. `HealthCheckService` degrades at `ConsecutiveFailures >= 3`.
|
||||
|
||||
1. Snapshot healthy nodes from the picker. If empty, throw `InvalidOperationException` with either "No historian nodes configured" or "All N historian nodes are in cooldown".
|
||||
2. For each candidate, clone `HistorianConfiguration` with the candidate as `ServerName` and pass it to the factory. On success: `MarkHealthy(node)` and return the `(Connection, Node)` tuple. On exception: `MarkFailed(node, ex.Message)`, log a warning, continue.
|
||||
3. If all candidates fail, wrap the last inner exception in an `InvalidOperationException` with the cumulative failure count so the existing read-method catch blocks surface a meaningful error through the health counters.
|
||||
### Quality mapping
|
||||
|
||||
The wrapping exception intentionally includes the last inner error message in the outer `Message` so the health snapshot's `LastError` field is still human-readable when the cluster exhausts every candidate.
|
||||
|
||||
### Single-node backward compatibility
|
||||
|
||||
When `ServerNames` is empty, the picker is seeded with a single entry from `ServerName` and the iteration loop still runs — it just has one candidate. Legacy deployments see no behavior change: the picker marks the single node healthy on success, runs the same cooldown logic on failure, and the dashboard renders a compact `Node: <hostname>` line instead of the cluster table.
|
||||
|
||||
### Cluster health surface
|
||||
|
||||
Runtime cluster state is exposed on `HistorianHealthSnapshot`:
|
||||
|
||||
- `NodeCount` / `HealthyNodeCount` -- size of the configured cluster and how many are currently eligible.
|
||||
- `ActiveProcessNode` / `ActiveEventNode` -- which nodes are currently serving the two connection silos, or `null` when a silo has no open connection.
|
||||
- `Nodes: List<HistorianClusterNodeState>` -- per-node state with `Name`, `IsHealthy`, `CooldownUntil`, `FailureCount`, `LastError`, `LastFailureTime`.
|
||||
|
||||
The dashboard renders this as a cluster table when `NodeCount > 1`. See [Status Dashboard](StatusDashboard.md#historian). `HealthCheckService` flips the overall service health to `Degraded` when `HealthyNodeCount < NodeCount` so operators can alert on a partially-failed cluster even while queries are still succeeding via the remaining nodes.
|
||||
|
||||
## Runtime Health Counters
|
||||
|
||||
`HistorianDataSource` maintains runtime query counters updated on every read method exit — success or failure — so the dashboard can distinguish "plugin loaded but never queried" from "plugin loaded and queries are failing". The load-time `HistorianPluginLoader.LastOutcome` only reports whether the assembly resolved at startup; it cannot catch a connection that succeeds at boot and degrades later.
|
||||
|
||||
### Counters
|
||||
|
||||
- `TotalQueries` / `TotalSuccesses` / `TotalFailures` — cumulative since startup. Every call to `RecordSuccess` or `RecordFailure` in the read methods updates these under `_healthLock`. Empty result sets count as successes — the counter reflects "the SDK call returned" rather than "the SDK call returned data".
|
||||
- `ConsecutiveFailures` — latches while queries are failing; reset to zero by the first success. Drives `HealthCheckService` degradation at threshold 3.
|
||||
- `LastSuccessTime` / `LastFailureTime` — UTC timestamps of the most recent success or failure, or `null` when no query of that outcome has occurred yet.
|
||||
- `LastError` — exception message from the most recent failure, prefixed with the read-path name (`raw:`, `aggregate:`, `at-time:`, `events:`) so operators can tell which SDK call is broken. Cleared on the next success.
|
||||
- `ProcessConnectionOpen` / `EventConnectionOpen` — whether the plugin currently holds an open SDK connection on each silo. Read from the data source's `_connection` / `_eventConnection` fields via a `Volatile.Read`.
|
||||
|
||||
These fields are read once per dashboard refresh via `IHistorianDataSource.GetHealthSnapshot()` and serialized into `HistorianStatusInfo`. See [Status Dashboard](StatusDashboard.md#historian) for the HTML/JSON surface.
|
||||
|
||||
### Two SDK connection silos
|
||||
|
||||
The plugin maintains two independent `ArchestrA.HistorianAccess` connections, one per `HistorianConnectionType`:
|
||||
|
||||
- **Process connection** (`ConnectionType = Process`) — serves historical *value* queries: `ReadRawAsync`, `ReadAggregateAsync`, `ReadAtTimeAsync`. This is the SDK's query channel for tags stored in the Historian runtime.
|
||||
- **Event connection** (`ConnectionType = Event`) — serves historical *event/alarm* queries: `ReadEventsAsync`. The SDK requires a separately opened connection for its event store because the query API and wire schema are distinct from value queries.
|
||||
|
||||
Both connections are lazy: they open on the first query that needs them. Either can be open, closed, or open against a different cluster node than the other. The dashboard renders both independently in the Historian panel (`Process Conn: open (host-a) | Event Conn: closed`) so operators can tell which silos are active and which node is serving each. When cluster support is configured, both silos share the same `HistorianClusterEndpointPicker`, so a failure on one silo marks the node unhealthy for the other as well.
|
||||
|
||||
## Raw Reads
|
||||
|
||||
`IHistorianDataSource.ReadRawAsync` (plugin implementation) uses a `HistoryQuery` to retrieve individual samples within a time range:
|
||||
|
||||
1. Create a `HistoryQuery` via `_connection.CreateHistoryQuery()`
|
||||
2. Configure `HistoryQueryArgs` with `TagNames`, `StartDateTime`, `EndDateTime`, and `RetrievalMode = Full`
|
||||
3. Iterate: `StartQuery` -> `MoveNext` loop -> `EndQuery`
|
||||
|
||||
Each result row is converted to an OPC UA `DataValue`:
|
||||
|
||||
- `QueryResult.Value` (double) takes priority; `QueryResult.StringValue` is used as fallback for string-typed tags.
|
||||
- `SourceTimestamp` and `ServerTimestamp` are both set to `QueryResult.StartDateTime`.
|
||||
- `StatusCode` is mapped from the `QueryResult.OpcQuality` (UInt16) via `QualityMapper` (the same OPC DA quality byte mapping used for live MXAccess data).
|
||||
|
||||
## Aggregate Reads
|
||||
|
||||
`IHistorianDataSource.ReadAggregateAsync` (plugin implementation) uses an `AnalogSummaryQuery` to retrieve pre-computed aggregates:
|
||||
|
||||
1. Create an `AnalogSummaryQuery` via `_connection.CreateAnalogSummaryQuery()`
|
||||
2. Configure `AnalogSummaryQueryArgs` with `TagNames`, `StartDateTime`, `EndDateTime`, and `Resolution` (milliseconds)
|
||||
3. Iterate the same `StartQuery` -> `MoveNext` -> `EndQuery` pattern
|
||||
4. Extract the requested aggregate from named properties on `AnalogSummaryQueryResult`
|
||||
|
||||
Null aggregate values return `BadNoData` status rather than `Good` with a null variant.
|
||||
|
||||
## Quality Mapping
|
||||
|
||||
The Historian SDK returns standard OPC DA quality values in `QueryResult.OpcQuality` (UInt16). The low byte is passed through the shared `QualityMapper` pipeline (`MapFromMxAccessQuality` -> `MapToOpcUaStatusCode`), which maps the OPC DA quality families to OPC UA status codes:
|
||||
The Historian SDK returns standard OPC DA quality values in `QueryResult.OpcQuality` (UInt16). The low byte flows through the shared `QualityMapper` pipeline (`MapFromMxAccessQuality` → `MapToOpcUaStatusCode`):
|
||||
|
||||
| OPC Quality Byte | OPC DA Family | OPC UA StatusCode |
|
||||
|---|---|---|
|
||||
|------------------|---------------|-------------------|
|
||||
| 0-63 | Bad | `Bad` (with sub-code when an exact enum match exists) |
|
||||
| 64-191 | Uncertain | `Uncertain` (with sub-code when an exact enum match exists) |
|
||||
| 192+ | Good | `Good` (with sub-code when an exact enum match exists) |
|
||||
|
||||
See `Domain/QualityMapper.cs` and `Domain/Quality.cs` for the full mapping table and sub-code definitions.
|
||||
See `Domain/QualityMapper.cs` and `Domain/Quality.cs` in `Driver.Galaxy.Host` for the full table.
|
||||
|
||||
## Aggregate Function Mapping
|
||||
## OPC UA Client — upstream forwarding
|
||||
|
||||
`HistorianAggregateMap.MapAggregateToColumn` (in the core Host assembly, so the node manager can validate aggregate support without requiring the plugin to be loaded) translates OPC UA aggregate NodeIds to `AnalogSummaryQueryResult` property names:
|
||||
The OPC UA Client driver (`Driver.OpcUaClient`) implements `IHistoryProvider` by forwarding each call to the upstream server via `Session.HistoryRead`. Raw / processed / at-time / events map onto the stack's native HistoryRead details types. Continuation points are passed through — the Core's `HistoryContinuationPointManager` treats the driver as an opaque pager.
|
||||
|
||||
| OPC UA Aggregate | Result Property |
|
||||
|---|---|
|
||||
| `AggregateFunction_Average` | `Average` |
|
||||
| `AggregateFunction_Minimum` | `Minimum` |
|
||||
| `AggregateFunction_Maximum` | `Maximum` |
|
||||
| `AggregateFunction_Count` | `ValueCount` |
|
||||
| `AggregateFunction_Start` | `First` |
|
||||
| `AggregateFunction_End` | `Last` |
|
||||
| `AggregateFunction_StandardDeviationPopulation` | `StdDev` |
|
||||
## Historizing flag and AccessLevel
|
||||
|
||||
Unsupported aggregates return `null`, which causes the node manager to return `BadAggregateNotSupported`.
|
||||
|
||||
## HistoryReadRawModified Override
|
||||
|
||||
`LmxNodeManager` overrides `HistoryReadRawModified` to handle raw history read requests:
|
||||
|
||||
1. Resolve the `NodeHandle` to a tag reference via `_nodeIdToTagReference`. Return `BadNodeIdUnknown` if not found.
|
||||
2. Check that `_historianDataSource` is not null. Return `BadHistoryOperationUnsupported` if historian is disabled.
|
||||
3. Call `ReadRawAsync` with the time range and `NumValuesPerNode` from the `ReadRawModifiedDetails`.
|
||||
4. Pack the resulting `DataValue` list into a `HistoryData` object and wrap it in an `ExtensionObject` for the `HistoryReadResult`.
|
||||
|
||||
## HistoryReadProcessed Override
|
||||
|
||||
`HistoryReadProcessed` handles aggregate history requests with additional validation:
|
||||
|
||||
1. Resolve the node and check historian availability (same as raw).
|
||||
2. Validate that `AggregateType` is present in the `ReadProcessedDetails`. Return `BadAggregateListMismatch` if empty.
|
||||
3. Map the requested aggregate to a result property via `MapAggregateToColumn`. Return `BadAggregateNotSupported` if unmapped.
|
||||
4. Call `ReadAggregateAsync` with the time range, `ProcessingInterval`, and property name.
|
||||
5. Return results in the same `HistoryData` / `ExtensionObject` format.
|
||||
|
||||
## Historizing Flag and AccessLevel
|
||||
|
||||
During variable node creation in `CreateAttributeVariable`, attributes with `IsHistorized == true` receive two additional settings:
|
||||
During variable node creation, drivers that advertise history set:
|
||||
|
||||
```csharp
|
||||
if (attr.IsHistorized)
|
||||
@@ -230,7 +111,13 @@ if (attr.IsHistorized)
|
||||
variable.Historizing = attr.IsHistorized;
|
||||
```
|
||||
|
||||
- **`Historizing = true`** -- Tells OPC UA clients that this node has historical data available.
|
||||
- **`AccessLevels.HistoryRead`** -- Enables the `HistoryRead` access bit on the node, which the OPC UA stack checks before routing history requests to the node manager override. Nodes without this bit set will be rejected by the framework before reaching `HistoryReadRawModified` or `HistoryReadProcessed`.
|
||||
- **`Historizing = true`** — tells OPC UA clients that the node has historical data available.
|
||||
- **`AccessLevels.HistoryRead`** — enables the `HistoryRead` access bit. The OPC UA stack checks this bit before routing history requests to the Core dispatcher; nodes without it are rejected before reaching `IHistoryProvider`.
|
||||
|
||||
The `IsHistorized` flag originates from the Galaxy repository database query, which checks whether the attribute has Historian logging configured.
|
||||
The `IsHistorized` flag originates in the driver's discovery output. For Galaxy it comes from the repository query detecting a `HistoryExtension` primitive (see [drivers/Galaxy-Repository.md](drivers/Galaxy-Repository.md)). For OPC UA Client it is copied from the upstream server's `Historizing` property.
|
||||
|
||||
## Configuration
|
||||
|
||||
Driver-specific historian config lives in each driver's `DriverConfig` JSON blob, validated against the driver type's `DriverConfigJsonSchema` in `DriverTypeRegistry`. The Galaxy driver's historian section carries the fields exercised by `HistorianConfiguration` — `ServerName` / `ServerNames`, `FailureCooldownSeconds`, `IntegratedSecurity` / `UserName` / `Password`, `Port` (default `32568`), `CommandTimeoutSeconds`, `RequestTimeoutSeconds`, `MaxValuesPerRead`. The OPC UA Client driver inherits its timeouts from the upstream session.
|
||||
|
||||
See [Configuration.md](Configuration.md) for the schema shape and validation path.
|
||||
|
||||
@@ -1,121 +1,65 @@
|
||||
# Incremental Sync
|
||||
|
||||
When a Galaxy redeployment is detected, the OPC UA address space must be updated to reflect the new hierarchy and attributes. Rather than tearing down the entire address space and rebuilding from scratch (which disconnects all clients and drops all subscriptions), `LmxNodeManager` performs an incremental sync that identifies changed objects and rebuilds only the affected subtrees.
|
||||
Two distinct change-detection paths feed the running server: driver-backend rediscovery (Galaxy's `time_of_last_deploy`, TwinCAT's symbol-version-changed, OPC UA Client's upstream namespace change) and generation-level config publishes from the Admin UI. Both flow into re-runs of `ITagDiscovery.DiscoverAsync`, but they originate differently.
|
||||
|
||||
## Cached State
|
||||
## Driver-backend rediscovery — IRediscoverable
|
||||
|
||||
`LmxNodeManager` retains shallow copies of the last-published hierarchy and attributes:
|
||||
Drivers whose backend has a native change signal implement `IRediscoverable` (`src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IRediscoverable.cs`):
|
||||
|
||||
```csharp
|
||||
private List<GalaxyObjectInfo>? _lastHierarchy;
|
||||
private List<GalaxyAttributeInfo>? _lastAttributes;
|
||||
```
|
||||
|
||||
These are updated at the end of every `BuildAddressSpace` or `SyncAddressSpace` call via `new List<T>(source)` to create independent copies. The copies serve as the baseline for the next diff comparison.
|
||||
|
||||
On the first call (when `_lastHierarchy` is null), `SyncAddressSpace` falls through to a full `BuildAddressSpace` since there is no baseline to diff against.
|
||||
|
||||
## AddressSpaceDiff
|
||||
|
||||
`AddressSpaceDiff` is a static helper class that computes the set of changed Galaxy object IDs between two snapshots.
|
||||
|
||||
### FindChangedGobjectIds
|
||||
|
||||
This method compares old and new hierarchy+attributes and returns a `HashSet<int>` of gobject IDs that have any difference. It detects three categories of changes:
|
||||
|
||||
**Added objects** -- Present in new hierarchy but not in old:
|
||||
|
||||
```csharp
|
||||
foreach (var id in newObjects.Keys)
|
||||
if (!oldObjects.ContainsKey(id))
|
||||
changed.Add(id);
|
||||
```
|
||||
|
||||
**Removed objects** -- Present in old hierarchy but not in new:
|
||||
|
||||
```csharp
|
||||
foreach (var id in oldObjects.Keys)
|
||||
if (!newObjects.ContainsKey(id))
|
||||
changed.Add(id);
|
||||
```
|
||||
|
||||
**Modified objects** -- Present in both but with different properties. `ObjectsEqual` compares `TagName`, `BrowseName`, `ContainedName`, `ParentGobjectId`, and `IsArea`.
|
||||
|
||||
**Attribute set changes** -- For objects that exist in both snapshots, attributes are grouped by `GobjectId` and compared pairwise. `AttributeSetsEqual` sorts both lists by `FullTagReference` and `PrimitiveName`, then checks each pair via `AttributesEqual`, which compares `AttributeName`, `FullTagReference`, `MxDataType`, `IsArray`, `ArrayDimension`, `PrimitiveName`, `SecurityClassification`, `IsHistorized`, and `IsAlarm`. A difference in count or any field mismatch marks the owning gobject as changed.
|
||||
|
||||
Objects already marked as changed by hierarchy comparison are skipped during attribute comparison to avoid redundant work.
|
||||
|
||||
### ExpandToSubtrees
|
||||
|
||||
When a Galaxy object changes, its children must also be rebuilt because they may reference the parent's node or have inherited attribute changes. `ExpandToSubtrees` performs a BFS traversal from each changed ID, adding all descendants:
|
||||
|
||||
```csharp
|
||||
public static HashSet<int> ExpandToSubtrees(HashSet<int> changed,
|
||||
List<GalaxyObjectInfo> hierarchy)
|
||||
public interface IRediscoverable
|
||||
{
|
||||
var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(h => h.GobjectId).ToList());
|
||||
|
||||
var expanded = new HashSet<int>(changed);
|
||||
var queue = new Queue<int>(changed);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var id = queue.Dequeue();
|
||||
if (childrenByParent.TryGetValue(id, out var children))
|
||||
foreach (var childId in children)
|
||||
if (expanded.Add(childId))
|
||||
queue.Enqueue(childId);
|
||||
}
|
||||
return expanded;
|
||||
event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
|
||||
}
|
||||
public sealed record RediscoveryEventArgs(string Reason, string? ScopeHint);
|
||||
```
|
||||
|
||||
The expansion runs against both the old and new hierarchy. This is necessary because a removed parent's children appear in the old hierarchy (for teardown) while an added parent's children appear in the new hierarchy (for construction).
|
||||
The driver fires the event with a reason string (for the diagnostic log) and an optional scope hint — a non-null hint lets Core scope the rebuild surgically to that subtree; null means "the whole address space may have changed".
|
||||
|
||||
## SyncAddressSpace Flow
|
||||
Drivers that implement the capability today:
|
||||
|
||||
`SyncAddressSpace` orchestrates the incremental update inside the OPC UA framework `Lock`:
|
||||
- **Galaxy** — polls `galaxy.time_of_last_deploy` in the Galaxy repository DB and fires on change. This is Galaxy-internal change detection, not the platform-wide mechanism.
|
||||
- **TwinCAT** — observes ADS symbol-version-changed notifications (`0x0702`).
|
||||
- **OPC UA Client** — subscribes to the upstream server's `Server/NamespaceArray` change notifications.
|
||||
|
||||
1. **Diff** -- Call `FindChangedGobjectIds` with the cached and new snapshots. If no changes are detected, update the cached snapshots and return early.
|
||||
Static drivers (Modbus, S7, AB CIP, AB Legacy, FOCAS) do not implement `IRediscoverable` — their tags only change when a new generation is published from the Config DB. Core sees absence of the interface and skips change-detection wiring for those drivers (decision #54).
|
||||
|
||||
2. **Expand** -- Call `ExpandToSubtrees` on both old and new hierarchies to include descendant objects.
|
||||
## Config-DB generation publishes
|
||||
|
||||
3. **Snapshot subscriptions** -- Before teardown, iterate `_gobjectToTagRefs` for each changed gobject ID and record the current MXAccess subscription ref-counts. These are needed to restore subscriptions after rebuild.
|
||||
Tag-set changes authored in the Admin UI (UNS edits, CSV imports, driver-config edits) accumulate in a draft generation and commit via `sp_PublishGeneration`. The delta between the currently-published generation and the proposed next one is computed by `sp_ComputeGenerationDiff`, which drives:
|
||||
|
||||
4. **Teardown** -- Call `TearDownGobjects` to remove the old nodes and clean up tracking state.
|
||||
- The **DiffViewer** in Admin (`src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor`) so operators can preview what will change before clicking Publish.
|
||||
- The 409-on-stale-draft flow (decision #161) — a UNS drag-reorder preview carries a `DraftRevisionToken` so Confirm returns `409 Conflict / refresh-required` if the draft advanced between preview and commit.
|
||||
|
||||
5. **Rebuild** -- Filter the new hierarchy and attributes to only the changed gobject IDs, then call `BuildSubtree` to create the replacement nodes.
|
||||
After publish, the server's generation applier invokes `IDriver.ReinitializeAsync(driverConfigJson, ct)` on every driver whose `DriverInstance.DriverConfig` row changed in the new generation. Reinitialize is the in-process recovery path for Tier A/B drivers; if it fails the driver is marked `DriverState.Faulted` and its nodes go Bad quality — but the server process stays running. See `docs/v2/driver-stability.md`.
|
||||
|
||||
6. **Restore subscriptions** -- For each previously subscribed tag reference that still exists in `_tagToVariableNode` after rebuild, re-open the MXAccess subscription and restore the original ref-count.
|
||||
Drivers whose discovery depends on Config DB state (Modbus register maps, S7 DBs, AB CIP tag lists) re-run their discovery inside `ReinitializeAsync`; Core then diffs the new node set against the current address space.
|
||||
|
||||
7. **Update cache** -- Replace `_lastHierarchy` and `_lastAttributes` with shallow copies of the new data.
|
||||
## Rebuild flow
|
||||
|
||||
## TearDownGobjects
|
||||
When a rediscovery is triggered (by either source), `GenericDriverNodeManager` re-runs `ITagDiscovery.DiscoverAsync` into the same `CapturingBuilder` it used at first build. The new node set is diffed against the current:
|
||||
|
||||
`TearDownGobjects` removes all OPC UA nodes and tracking state for a set of gobject IDs:
|
||||
1. **Diff** — full-name comparison of the new `DriverAttributeInfo` set against the existing `_variablesByFullRef` map. Added / removed / modified references are partitioned.
|
||||
2. **Snapshot subscriptions** — before teardown, Core captures the current monitored-item ref-counts for every affected reference so subscriptions can be replayed after rebuild.
|
||||
3. **Teardown** — removed / modified variable nodes are deleted via `CustomNodeManager2.DeleteNode`. Driver-side subscriptions for those references are unwound via `ISubscribable.UnsubscribeAsync`.
|
||||
4. **Rebuild** — added / modified references get fresh `BaseDataVariableState` nodes via the standard `IAddressSpaceBuilder.Variable(...)` path. Alarm-flagged references re-register their `IAlarmConditionSink` through `CapturingBuilder`.
|
||||
5. **Restore subscriptions** — for every captured reference that still exists after rebuild, Core re-opens the driver subscription and restores the original ref-count.
|
||||
|
||||
For each gobject ID, it processes the associated tag references from `_gobjectToTagRefs`:
|
||||
Exceptions during teardown are swallowed per decision #12 — a driver throw must not leave the node tree half-deleted.
|
||||
|
||||
1. **Unsubscribe** -- If the tag has an active MXAccess subscription (entry in `_subscriptionRefCounts`), call `UnsubscribeAsync` and remove the ref-count entry.
|
||||
## Scope hint
|
||||
|
||||
2. **Remove alarm tracking** -- Find any `_alarmInAlarmTags` entries whose `SourceTagReference` matches the tag. For each, unsubscribe the InAlarm, Priority, and DescAttrName tags, then remove the alarm entry.
|
||||
When `RediscoveryEventArgs.ScopeHint` is non-null (e.g. a folder path), Core restricts the diff to that subtree. This matters for Galaxy Platform-scoped deployments where a `time_of_last_deploy` advance may only affect one platform's subtree, and for OPC UA Client where an upstream change may be localized. Null scope falls back to a full-tree diff.
|
||||
|
||||
3. **Delete variable node** -- Call `DeleteNode` on the variable's `NodeId`, remove from `_tagToVariableNode`, clean up `_nodeIdToTagReference` and `_tagMetadata`, and decrement `VariableNodeCount`.
|
||||
## Active subscriptions survive rebuild
|
||||
|
||||
4. **Delete object/folder node** -- Remove the gobject's entry from `_nodeMap` and call `DeleteNode`. Non-folder nodes decrement `ObjectNodeCount`.
|
||||
Subscriptions for unchanged references stay live across rebuilds — their ref-count map is not disturbed. Clients monitoring a stable tag never see a data-change gap during a deploy, only clients monitoring a tag that was genuinely removed see the subscription drop.
|
||||
|
||||
All MXAccess calls and `DeleteNode` calls are wrapped in try/catch with ignored exceptions, since teardown must complete even if individual cleanup steps fail.
|
||||
## Key source files
|
||||
|
||||
## BuildSubtree
|
||||
|
||||
`BuildSubtree` creates OPC UA nodes for a subset of the Galaxy hierarchy, reusing existing parent nodes from `_nodeMap`.
|
||||
|
||||
The method first topologically sorts the input hierarchy (same `TopologicalSort` used by `BuildAddressSpace`) to ensure parents are created before children. For each object:
|
||||
|
||||
1. **Find parent** -- Look up `ParentGobjectId` in `_nodeMap`. If the parent was not part of the changed set, it already exists from the previous build. If no parent is found, fall back to the root `ZB` folder. This is the key difference from `BuildAddressSpace` -- subtree builds reuse the existing node tree rather than starting from the root.
|
||||
|
||||
2. **Create node** -- Areas become `FolderState` with `Organizes` reference; non-areas become `BaseObjectState` with `HasComponent` reference. The node is added to `_nodeMap`.
|
||||
|
||||
3. **Create variable nodes** -- Attributes are processed with the same primitive-grouping logic as `BuildAddressSpace`, creating `BaseDataVariableState` nodes via `CreateAttributeVariable`.
|
||||
|
||||
4. **Alarm tracking** -- If `_alarmTrackingEnabled` is set, alarm attributes are detected and `AlarmConditionState` nodes are created using the same logic as the full build. EventNotifier flags are set on parent nodes, and alarm tags are auto-subscribed.
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IRediscoverable.cs` — backend-change capability
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — discovery orchestration
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs` — `ReinitializeAsync` contract
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Admin/Services/GenerationService.cs` — publish-flow driver
|
||||
- `docs/v2/config-db-schema.md` — `sp_PublishGeneration` + `sp_ComputeGenerationDiff`
|
||||
- `docs/v2/admin-ui.md` — DiffViewer + draft-revision-token flow
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
# MXAccess Bridge
|
||||
|
||||
The MXAccess bridge connects the OPC UA server to the AVEVA System Platform runtime through the `ArchestrA.MxAccess` COM API. It handles all COM threading requirements, translates between OPC UA read/write requests and MXAccess operations, and manages connection health.
|
||||
|
||||
## STA Thread Requirement
|
||||
|
||||
MXAccess is a COM-based API that requires a Single-Threaded Apartment (STA). All COM objects -- `LMXProxyServer` instantiation, `Register`, `AddItem`, `AdviseSupervisory`, `Write`, and cleanup calls -- must execute on the same STA thread. Calling COM objects from the wrong thread causes marshalling failures or silent data corruption.
|
||||
|
||||
`StaComThread` provides a dedicated STA thread with the apartment state set before the thread starts:
|
||||
|
||||
```csharp
|
||||
_thread = new Thread(ThreadEntry) { Name = "MxAccess-STA", IsBackground = true };
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
```
|
||||
|
||||
Work items are queued via `RunAsync(Action)` or `RunAsync<T>(Func<T>)`, which enqueue the work to a `ConcurrentQueue<Action>` and post a `WM_APP` message to wake the pump. Each work item is wrapped in a `TaskCompletionSource` so callers can `await` the result from any thread.
|
||||
|
||||
## Win32 Message Pump
|
||||
|
||||
COM callbacks (like `OnDataChange`) are delivered through the Windows message loop. `StaComThread` runs a standard Win32 message pump using P/Invoke:
|
||||
|
||||
1. `PeekMessage` primes the message queue (required before `PostThreadMessage` works)
|
||||
2. `GetMessage` blocks until a message arrives
|
||||
3. `WM_APP` messages drain the work queue
|
||||
4. `WM_APP + 1` drains the queue and posts `WM_QUIT` to exit the loop
|
||||
5. All other messages are passed through `TranslateMessage`/`DispatchMessage` for COM callback delivery
|
||||
|
||||
Without this message pump, MXAccess COM callbacks would never fire and the server would receive no live data.
|
||||
|
||||
## LMXProxyServer COM Object
|
||||
|
||||
`MxProxyAdapter` wraps the real `ArchestrA.MxAccess.LMXProxyServer` COM object behind the `IMxProxy` interface. This abstraction allows unit tests to substitute a fake proxy without requiring the ArchestrA runtime.
|
||||
|
||||
The COM object lifecycle:
|
||||
|
||||
1. **`Register(clientName)`** -- Creates a new `LMXProxyServer` instance, wires up `OnDataChange` and `OnWriteComplete` event handlers, and calls `Register` to obtain a connection handle
|
||||
2. **`Unregister(handle)`** -- Unwires event handlers, calls `Unregister`, and releases the COM object via `Marshal.ReleaseComObject`
|
||||
|
||||
## Register/AddItem/AdviseSupervisory Pattern
|
||||
|
||||
Every MXAccess data operation follows a three-step pattern, all executed on the STA thread:
|
||||
|
||||
1. **`AddItem(handle, address)`** -- Resolves a Galaxy tag reference (e.g., `TestMachine_001.MachineID`) to an integer item handle
|
||||
2. **`AdviseSupervisory(handle, itemHandle)`** -- Subscribes the item for supervisory data change callbacks
|
||||
3. The runtime begins delivering `OnDataChange` events for the item
|
||||
|
||||
For writes, after `AddItem` + `AdviseSupervisory`, `Write(handle, itemHandle, value, securityClassification)` sends the value to the runtime. The `OnWriteComplete` callback confirms or rejects the write.
|
||||
|
||||
Cleanup reverses the pattern: `UnAdviseSupervisory` then `RemoveItem`.
|
||||
|
||||
## OnDataChange and OnWriteComplete Callbacks
|
||||
|
||||
### OnDataChange
|
||||
|
||||
Fired by the COM runtime on the STA thread when a subscribed tag value changes. The handler in `MxAccessClient.EventHandlers.cs`:
|
||||
|
||||
1. Maps the integer `phItemHandle` back to a tag address via `_handleToAddress`
|
||||
2. Maps the MXAccess quality code to the internal `Quality` enum
|
||||
3. Checks `MXSTATUS_PROXY` for error details and adjusts quality accordingly
|
||||
4. Converts the timestamp to UTC
|
||||
5. Constructs a `Vtq` (Value/Timestamp/Quality) and delivers it to:
|
||||
- The stored per-tag subscription callback
|
||||
- Any pending one-shot read completions
|
||||
- The global `OnTagValueChanged` event (consumed by `LmxNodeManager`)
|
||||
|
||||
### OnWriteComplete
|
||||
|
||||
Fired when the runtime acknowledges or rejects a write. The handler resolves the pending `TaskCompletionSource<bool>` for the item handle. If `MXSTATUS_PROXY.success == 0`, the write is considered failed and the error detail is logged.
|
||||
|
||||
## Reconnection Logic
|
||||
|
||||
`MxAccessClient` implements automatic reconnection through two mechanisms:
|
||||
|
||||
### Monitor loop
|
||||
|
||||
`StartMonitor` launches a background task that polls at `MonitorIntervalSeconds`. On each cycle:
|
||||
|
||||
- If the state is `Disconnected` or `Error` and `AutoReconnect` is enabled, it calls `ReconnectAsync`
|
||||
- If connected and a probe tag is configured, it checks the probe staleness threshold
|
||||
|
||||
### Reconnect sequence
|
||||
|
||||
`ReconnectAsync` performs a full disconnect-then-connect cycle:
|
||||
|
||||
1. Increment the reconnect counter
|
||||
2. `DisconnectAsync` -- Tears down all active subscriptions (`UnAdviseSupervisory` + `RemoveItem` for each), detaches COM event handlers, calls `Unregister`, and clears all handle mappings
|
||||
3. `ConnectAsync` -- Creates a fresh `LMXProxyServer`, registers, replays all stored subscriptions, and re-subscribes the probe tag
|
||||
|
||||
Stored subscriptions (`_storedSubscriptions`) persist across reconnects. When `ConnectAsync` succeeds, `ReplayStoredSubscriptionsAsync` iterates all stored entries and calls `AddItem` + `AdviseSupervisory` for each.
|
||||
|
||||
## Probe Tag Health Monitoring
|
||||
|
||||
A configurable probe tag (e.g., a frequently updating Galaxy attribute) serves as a connection health indicator. After connecting, the client subscribes to the probe tag and records `_lastProbeValueTime` on every `OnDataChange` callback.
|
||||
|
||||
The monitor loop compares `DateTime.UtcNow - _lastProbeValueTime` against `ProbeStaleThresholdSeconds`. If the probe value has not updated within the threshold, the connection is assumed stale and a reconnect is forced. This catches scenarios where the COM connection is technically alive but the runtime has stopped delivering data.
|
||||
|
||||
## Per-Host Runtime Status Probes (`<Host>.ScanState`)
|
||||
|
||||
Separate from the connection-level probe above, the bridge advises `<HostName>.ScanState` on every deployed `$WinPlatform` and `$AppEngine` in the Galaxy. These probes track per-host runtime state so the dashboard can report "this specific Platform / AppEngine is off scan" and the bridge can proactively invalidate every OPC UA variable hosted by the stopped object — preventing MxAccess from serving stale Good-quality cached values to clients who read those tags while the host is down.
|
||||
|
||||
Enabled by default via `MxAccess.RuntimeStatusProbesEnabled`; see [Configuration](Configuration.md#mxaccess) for the two config fields.
|
||||
|
||||
### How it works
|
||||
|
||||
`GalaxyRuntimeProbeManager` is owned by `LmxNodeManager` and operates on a simple three-state machine per host (Unknown / Running / Stopped):
|
||||
|
||||
1. **Discovery** — After `BuildAddressSpace` completes, the manager filters the hierarchy to rows where `CategoryId == 1` (`$WinPlatform`) or `CategoryId == 3` (`$AppEngine`) and issues `AdviseSupervisory` for `<TagName>.ScanState` on each one. Probes are bridge-owned, not ref-counted against client subscriptions, and persist across address-space rebuilds via a `Sync` diff.
|
||||
2. **Transition predicate** — A probe callback is interpreted as `isRunning = vtq.Quality.IsGood() && vtq.Value is bool b && b`. Everything else (explicit `ScanState = false`, bad quality, communication errors from the broker) means **Stopped**.
|
||||
3. **On-change-only delivery** — `ScanState` is delivered **only when the value actually changes**. A stably Running host may go hours without a callback. The probe manager's `Tick()` explicitly does NOT run a starvation check on Running entries — the only time-based transition is **Unknown → Stopped** when the initial callback hasn't arrived within `RuntimeStatusUnknownTimeoutSeconds` (default 15s). This protects against a probe that fails to resolve at all without incorrectly flipping healthy long-running hosts.
|
||||
4. **Transport gating** — When `IMxAccessClient.State != Connected`, `GetSnapshot()` forces every entry to `Unknown` regardless of underlying state. The dashboard shows the Connection panel as the primary signal in that case rather than misleading operators with "every host stopped."
|
||||
5. **Subscribe failure rollback** — If `SubscribeAsync` throws for a new probe (SDK failure, broker rejection, transport error), the manager rolls back both `_byProbe` and `_probeByGobjectId` so the probe never appears in `GetSnapshot()`. Without this rollback, a failed subscribe would leave the entry in `Unknown` forever, and `Tick()` would later transition it to `Stopped` after the unknown-resolution timeout, fanning out a **false-negative** host-down signal that invalidates the subtree of a host that was never actually advised. Stability review 2026-04-13 Finding 1.
|
||||
|
||||
### Subtree quality invalidation on transition
|
||||
|
||||
When a host transitions **Running → Stopped**, the probe manager invokes a callback that walks `_hostedVariables[gobjectId]` — the set of every OPC UA variable transitively hosted by that Galaxy object — and sets each variable's `StatusCode` to `BadOutOfService`. The reverse happens on **Stopped → Running**: `ClearHostVariablesBadQuality` resets each to `Good` and lets subsequent on-change MxAccess updates repopulate the values.
|
||||
|
||||
The hosted-variables map is built once per `BuildAddressSpace` by walking each object's `HostedByGobjectId` chain up to the nearest Platform or Engine ancestor. A variable hosted by an Engine inside a Platform ends up in **both** the Engine's list and the Platform's list, so stopping the Platform transitively invalidates every descendant Engine's variables.
|
||||
|
||||
### Read-path short-circuit (`IsTagUnderStoppedHost`)
|
||||
|
||||
`LmxNodeManager.Read` override is called by the OPC UA SDK for both direct Read requests and monitored-item sampling. It previously called `_mxAccessClient.ReadAsync(tagRef)` unconditionally and returned whatever VTQ the runtime reported. That created a gap: MxAccess happily serves the last cached value as Good on a tag whose hosting Engine has gone off scan.
|
||||
|
||||
The Read override now checks `IsTagUnderStoppedHost(tagRef)` (a reverse-index lookup `_hostIdsByTagRef[tagRef]` → `GalaxyRuntimeProbeManager.IsHostStopped(hostId)`) before the MxAccess round-trip. When the owning host is Stopped, the handler returns a synthesized `DataValue { Value = cachedVar.Value, StatusCode = BadOutOfService }` directly without touching MxAccess. This guarantees clients see a uniform `BadOutOfService` on every descendant tag of a stopped host, regardless of whether they're reading or subscribing.
|
||||
|
||||
### Deferred dispatch: the STA deadlock
|
||||
|
||||
**Critical**: probe transition callbacks must **not** run synchronously on the STA thread that delivered the `OnDataChange`. `MarkHostVariablesBadQuality` takes the `LmxNodeManager.Lock`, which may be held by a worker thread currently inside `Read` waiting on an `_mxAccessClient.ReadAsync()` round-trip that is itself waiting for the STA thread. Classic circular wait — the first real deploy of this feature hung inside 30 seconds from exactly this pattern.
|
||||
|
||||
The fix is a deferred-dispatch queue: probe callbacks enqueue the transition onto `ConcurrentQueue<(int GobjectId, bool Stopped)>` and set the existing dispatch signal. The dispatch thread drains the queue inside its existing 100ms `WaitOne` loop — **outside** any locks held by the STA path — and then calls `MarkHostVariablesBadQuality` / `ClearHostVariablesBadQuality` under its own natural `Lock` acquisition. No circular wait, no STA dispatch involvement.
|
||||
|
||||
See the `runtimestatus.md` plan file and the `service_info.md` entry for the in-flight debugging that led to this pattern.
|
||||
|
||||
### Dashboard + health surface
|
||||
|
||||
- Dashboard **Galaxy Runtime** panel between Galaxy Info and Historian shows per-host state with Name / Kind / State / Since / Last Error columns. Panel color is green (all Running), yellow (any Unknown, none Stopped), red (any Stopped), gray (MxAccess transport disconnected).
|
||||
- Subscriptions panel gains a `Probes: N (bridge-owned runtime status)` line when at least one probe is active, so operators can distinguish bridge-owned probe count from client-driven subscriptions.
|
||||
- `HealthCheckService.CheckHealth` Rule 2e rolls overall health to `Degraded` when any host is Stopped, ordered after the MxAccess-transport check (Rule 1) so a transport outage stays `Unhealthy` without double-messaging.
|
||||
|
||||
See [Status Dashboard](StatusDashboard.md#galaxy-runtime) for the field table and [Configuration](Configuration.md#mxaccess) for the two new config fields.
|
||||
|
||||
## Request Timeout Safety Backstop
|
||||
|
||||
Every sync-over-async site on the OPC UA stack thread that calls into MxAccess (`Read`, `Write`, address-space rebuild probe sync) is wrapped in a bounded `SyncOverAsync.WaitSync(...)` helper with timeout `MxAccess.RequestTimeoutSeconds` (default 30s). This is a backstop: `MxAccessClient.Read/Write` already enforce inner `ReadTimeoutSeconds` / `WriteTimeoutSeconds` bounds on the async path. The outer wrapper exists so a scheduler stall, slow reconnect, or any other non-returning async path cannot park the stack thread indefinitely.
|
||||
|
||||
On timeout, the underlying task is **not** cancelled — it runs to completion on the thread pool and is abandoned. This is acceptable because MxAccess clients are shared singletons and the abandoned continuation does not capture request-scoped state. The OPC UA stack receives `StatusCodes.BadTimeout` on the affected operation.
|
||||
|
||||
`ConfigurationValidator` enforces `RequestTimeoutSeconds >= 1` and warns when it is set below the inner Read/Write timeouts (operator misconfiguration). Stability review 2026-04-13 Finding 3.
|
||||
|
||||
## Why Marshal.ReleaseComObject Is Needed
|
||||
|
||||
The .NET runtime's garbage collector releases COM references non-deterministically. For MXAccess, delayed release can leave stale COM connections open, preventing clean re-registration. `MxProxyAdapter.Unregister` calls `Marshal.ReleaseComObject(_lmxProxy)` in a `finally` block to immediately release the COM reference count to zero. This ensures the underlying COM server is freed before a reconnect attempt creates a new instance.
|
||||
|
||||
## Key source files
|
||||
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/StaComThread.cs` -- STA thread and Win32 message pump
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.cs` -- Core client class (partial)
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Connection.cs` -- Connect, disconnect, reconnect
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Subscription.cs` -- Subscribe, unsubscribe, replay
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs` -- Read and write operations
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.EventHandlers.cs` -- OnDataChange and OnWriteComplete handlers
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Monitor.cs` -- Background health monitor
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxProxyAdapter.cs` -- COM object wrapper
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/GalaxyRuntimeProbeManager.cs` -- Per-host `ScanState` probes, state machine, `IsHostStopped` lookup
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyRuntimeStatus.cs` -- Per-host DTO
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/Domain/GalaxyRuntimeState.cs` -- `Unknown` / `Running` / `Stopped` enum
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/Domain/IMxAccessClient.cs` -- Client interface
|
||||
@@ -1,137 +1,88 @@
|
||||
# OPC UA Server
|
||||
|
||||
The OPC UA server component hosts the Galaxy-backed namespace on a configurable TCP endpoint and exposes deployed System Platform objects and attributes to OPC UA clients.
|
||||
The OPC UA server component (`src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs`) hosts the OPC UA stack and exposes one browsable subtree per registered driver. The server itself is driver-agnostic — Galaxy/MXAccess, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client are all plugged in as `IDriver` implementations via the capability interfaces in `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`.
|
||||
|
||||
## Composition
|
||||
|
||||
`OtOpcUaServer` subclasses the OPC Foundation `StandardServer` and wires:
|
||||
|
||||
- A `DriverHost` (`src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs`) which registers drivers and holds the per-instance `IDriver` references.
|
||||
- One `DriverNodeManager` per registered driver (`src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs`), constructed in `CreateMasterNodeManager`. Each manager owns its own namespace URI (`urn:OtOpcUa:{DriverInstanceId}`) and exposes the driver as a subtree under the standard `Objects` folder.
|
||||
- A `CapabilityInvoker` (`src/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs`) per driver instance, keyed on `(DriverInstanceId, HostName, DriverCapability)` against the shared `DriverResiliencePipelineBuilder`. Every Read/Write/Discovery/Subscribe/HistoryRead/AlarmSubscribe call on the driver flows through this invoker so the Polly pipeline (retry / timeout / breaker / bulkhead) applies. The OTOPCUA0001 Roslyn analyzer enforces the wrapping at compile time.
|
||||
- An `IUserAuthenticator` (LDAP in production, injected stub in tests) for `UserName` token validation in the `ImpersonateUser` hook.
|
||||
- Optional `AuthorizationGate` + `NodeScopeResolver` (Phase 6.2) that sit in front of every dispatch call. In lax mode the gate passes through when the identity lacks LDAP groups so existing integration tests keep working; strict mode (`Authorization:StrictMode = true`) denies those cases.
|
||||
|
||||
`OtOpcUaServer.DriverNodeManagers` exposes the materialized list so the hosting layer can walk each one post-start and call `GenericDriverNodeManager.BuildAddressSpaceAsync(manager)` — the manager is passed as its own `IAddressSpaceBuilder`.
|
||||
|
||||
## Configuration
|
||||
|
||||
`OpcUaConfiguration` defines the server endpoint and session settings. All properties have sensible defaults:
|
||||
Server wiring used to live in `appsettings.json`. It now flows from the SQL Server **Config DB**: `ServerInstance` + `DriverInstance` + `Tag` + `NodeAcl` rows are published as a *generation* via `sp_PublishGeneration` and loaded into the running process by the generation applier. The Admin UI (Blazor Server, `docs/v2/admin-ui.md`) is the operator surface — drafts accumulate edits; `sp_ComputeGenerationDiff` drives the DiffViewer preview; a UNS drag-reorder carries a `DraftRevisionToken` so Confirm re-checks against the current draft and returns 409 if it advanced (decision #161). See `docs/v2/config-db-schema.md` for the schema.
|
||||
|
||||
| Property | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `BindAddress` | `0.0.0.0` | IP address or hostname the server binds to |
|
||||
| `Port` | `4840` | TCP port the server listens on |
|
||||
| `EndpointPath` | `/LmxOpcUa` | URI path appended to the base address |
|
||||
| `ServerName` | `LmxOpcUa` | Application name presented to clients |
|
||||
| `GalaxyName` | `ZB` | Galaxy name used in the namespace URI |
|
||||
| `MaxSessions` | `100` | Maximum concurrent client sessions |
|
||||
| `SessionTimeoutMinutes` | `30` | Idle session timeout |
|
||||
| `AlarmTrackingEnabled` | `false` | Enables `AlarmConditionState` nodes for alarm attributes |
|
||||
| `AlarmFilter.ObjectFilters` | `[]` | Wildcard template-name patterns that scope alarm tracking to matching objects and their descendants (see [Alarm Tracking](AlarmTracking.md#template-based-alarm-object-filter)) |
|
||||
Environmental knobs that aren't per-tenant (bind address, port, PKI path) still live in `appsettings.json` on the Server project; everything tenant-scoped moved to the Config DB.
|
||||
|
||||
The resulting endpoint URL is `opc.tcp://{BindAddress}:{Port}{EndpointPath}`, e.g., `opc.tcp://0.0.0.0:4840/LmxOpcUa`.
|
||||
## Transport
|
||||
|
||||
The namespace URI follows the pattern `urn:{GalaxyName}:LmxOpcUa` and is used as the `ProductUri`. The `ApplicationUri` can be set independently via `OpcUa.ApplicationUri` to support redundant deployments where each instance needs a unique identity. When `ApplicationUri` is null, it defaults to the namespace URI.
|
||||
The server binds one TCP endpoint per `ServerInstance` (default `opc.tcp://0.0.0.0:4840`). The `ApplicationConfiguration` is built programmatically in the `OpcUaApplicationHost` — there are no UA XML files. Security profiles (`None`, `Basic256Sha256-Sign`, `Basic256Sha256-SignAndEncrypt`) are resolved from the `ServerInstance.Security` JSON at startup; the default profile is still `None` for backward compatibility. User token policies (`Anonymous`, `UserName`) are attached based on whether LDAP is configured. See `docs/security.md` for hardening.
|
||||
|
||||
## Programmatic ApplicationConfiguration
|
||||
## Session impersonation
|
||||
|
||||
`OpcUaServerHost` builds the entire `ApplicationConfiguration` in code. There are no XML configuration files. This keeps deployment simple on factory floor machines where editing XML is error-prone.
|
||||
`OtOpcUaServer.OnImpersonateUser` handles the three token types:
|
||||
|
||||
The configuration covers:
|
||||
- `AnonymousIdentityToken` → default anonymous `UserIdentity`.
|
||||
- `UserNameIdentityToken` → `IUserAuthenticator.AuthenticateAsync` validates the credential (`LdapUserAuthenticator` in production). On success, the resolved display name + LDAP-derived roles are wrapped in a `RoleBasedIdentity` that implements `IRoleBearer`. `DriverNodeManager.OnWriteValue` reads these roles via `context.UserIdentity is IRoleBearer` and applies `WriteAuthzPolicy` per write.
|
||||
- Anything else → `BadIdentityTokenInvalid`.
|
||||
|
||||
- **ServerConfiguration** -- base address, session limits, security policies, and user token policies
|
||||
- **SecurityConfiguration** -- certificate store paths under `%LOCALAPPDATA%\OPC Foundation\pki\`, auto-accept enabled
|
||||
- **TransportQuotas** -- 4 MB max message/string/byte-string size, 120-second operation timeout, 1-hour security token lifetime
|
||||
- **TraceConfiguration** -- OPC Foundation SDK tracing is disabled (output path `null`, trace masks `0`); all logging goes through Serilog instead
|
||||
The Phase 6.2 `AuthorizationGate` runs on top of this baseline: when configured it consults the cluster's permission trie (loaded from `NodeAcl` rows) using the session's `UserAuthorizationState` and can deny Read / HistoryRead / Write / Browse independently per tag. See `docs/v2/acl-design.md`.
|
||||
|
||||
## Security Profiles
|
||||
## Dispatch
|
||||
|
||||
The server supports configurable transport security profiles controlled by the `Security` section in `appsettings.json`. The default configuration exposes only `MessageSecurityMode.None` for backward compatibility.
|
||||
Every service call the stack hands to `DriverNodeManager` is translated to the driver's capability interface and routed through `CapabilityInvoker`:
|
||||
|
||||
Supported Phase 1 profiles:
|
||||
|
||||
| Profile Name | SecurityPolicy URI | MessageSecurityMode |
|
||||
| Service | Capability | Invoker method |
|
||||
|---|---|---|
|
||||
| `None` | `SecurityPolicy#None` | `None` |
|
||||
| `Basic256Sha256-Sign` | `SecurityPolicy#Basic256Sha256` | `Sign` |
|
||||
| `Basic256Sha256-SignAndEncrypt` | `SecurityPolicy#Basic256Sha256` | `SignAndEncrypt` |
|
||||
| Read | `IReadable.ReadAsync` | `ExecuteAsync(DriverCapability.Read, host, …)` |
|
||||
| Write | `IWritable.WriteAsync` | `ExecuteWriteAsync(host, isIdempotent, …)` — honors `WriteIdempotentAttribute` (#143) |
|
||||
| CreateMonitoredItems / DeleteMonitoredItems | `ISubscribable.SubscribeAsync/UnsubscribeAsync` | `ExecuteAsync(DriverCapability.Subscribe, host, …)` |
|
||||
| HistoryRead (raw / processed / at-time / events) | `IHistoryProvider.*Async` | `ExecuteAsync(DriverCapability.HistoryRead, host, …)` |
|
||||
| ConditionRefresh / Acknowledge | `IAlarmSource.*Async` | via `AlarmSurfaceInvoker` (fans out per host) |
|
||||
|
||||
`SecurityProfileResolver` maps configured profile names to `ServerSecurityPolicy` instances at startup. Unknown names are skipped with a warning, and an empty or invalid list falls back to `None`.
|
||||
|
||||
For production deployments, configure `["Basic256Sha256-SignAndEncrypt"]` or `["None", "Basic256Sha256-SignAndEncrypt"]` and set `AutoAcceptClientCertificates` to `false`. See the [Security Guide](security.md) for hardening details.
|
||||
The host name fed to the invoker comes from `IPerCallHostResolver.ResolveHost(fullReference)` when the driver implements it (multi-host drivers: AB CIP, Modbus with per-device options). Single-host drivers fall back to `DriverInstanceId`, preserving pre-Phase-6.1 pipeline-key semantics (decision #144).
|
||||
|
||||
## Redundancy
|
||||
|
||||
When `Redundancy.Enabled = true`, `LmxOpcUaServer` exposes the standard OPC UA redundancy nodes on startup:
|
||||
`Redundancy.Enabled = true` on the `ServerInstance` activates the `RedundancyCoordinator` + `ServiceLevelCalculator` (`src/ZB.MOM.WW.OtOpcUa.Server/Redundancy/`). Standard OPC UA redundancy nodes (`Server/ServerRedundancy/RedundancySupport`, `ServerUriArray`, `Server/ServiceLevel`) are populated on startup; `ServiceLevel` recomputes whenever any driver's `DriverHealth` changes. The apply-lease mechanism prevents two instances from concurrently applying a generation. See `docs/Redundancy.md`.
|
||||
|
||||
- `Server/ServerRedundancy/RedundancySupport` — set to `Warm` or `Hot` based on configuration
|
||||
- `Server/ServerRedundancy/ServerUriArray` — populated with the configured `ServerUris`
|
||||
- `Server/ServiceLevel` — computed dynamically from role and runtime health
|
||||
## Server class hierarchy
|
||||
|
||||
The `ServiceLevel` is updated whenever MXAccess connection state changes or Galaxy DB health changes. See [Redundancy Guide](Redundancy.md) for full details.
|
||||
### OtOpcUaServer extends StandardServer
|
||||
|
||||
### User token policies
|
||||
- **`CreateMasterNodeManager`** — Iterates `_driverHost.RegisteredDriverIds`, builds one `DriverNodeManager` per driver with its own `CapabilityInvoker` + resilience options (tier from `DriverTypeRegistry`, per-instance JSON overrides from `DriverInstance.ResilienceConfig` via `DriverResilienceOptionsParser`). The managers are wrapped in a `MasterNodeManager` with no additional core managers.
|
||||
- **`OnServerStarted`** — Hooks `SessionManager.ImpersonateUser` for LDAP auth. Redundancy + server-capability population happens via `OpcUaApplicationHost`.
|
||||
- **`LoadServerProperties`** — Manufacturer `OtOpcUa`, Product `OtOpcUa.Server`, ProductUri `urn:OtOpcUa:Server`.
|
||||
|
||||
`UserTokenPolicies` are dynamically configured based on the `Authentication` settings in `appsettings.json`:
|
||||
### ServerCapabilities
|
||||
|
||||
- An `Anonymous` user token policy is added when `AllowAnonymous` is `true` (the default).
|
||||
- A `UserName` user token policy is added when an authentication provider is configured (LDAP or injected).
|
||||
|
||||
Both policies can be active simultaneously, allowing clients to connect with or without credentials.
|
||||
|
||||
### Session impersonation
|
||||
|
||||
When a client presents `UserName` credentials, the server validates them through `IUserAuthenticationProvider`. If the provider also implements `IRoleProvider` (as `LdapAuthenticationProvider` does), LDAP group membership is resolved once during authentication and mapped to custom OPC UA role `NodeId`s in a dedicated `urn:zbmom:lmxopcua:roles` namespace. These role NodeIds are added to the session's `RoleBasedIdentity.GrantedRoleIds`.
|
||||
|
||||
Anonymous sessions receive `WellKnownRole_Anonymous`. Authenticated sessions receive `WellKnownRole_AuthenticatedUser` plus any LDAP-derived role NodeIds. Permission checks in `LmxNodeManager` inspect `GrantedRoleIds` directly — no username extraction or side-channel cache is needed.
|
||||
|
||||
`AnonymousCanWrite` controls whether anonymous sessions can write, regardless of whether LDAP is enabled.
|
||||
`OpcUaApplicationHost` populates `Server/ServerCapabilities` with `StandardUA2017`, `en` locale, 100 ms `MinSupportedSampleRate`, 4 MB message caps, and per-operation limits (1000 per Read/Write/Browse/TranslateBrowsePaths/MonitoredItems/HistoryRead; 0 for MethodCall/NodeManagement/HistoryUpdate).
|
||||
|
||||
## Certificate handling
|
||||
|
||||
On startup, `OpcUaServerHost.StartAsync` calls `CheckApplicationInstanceCertificate(false, minKeySize)` to locate or create a self-signed certificate meeting the configured minimum key size (default 2048). The certificate subject defaults to `CN={ServerName}, O=ZB MOM, DC=localhost` but can be overridden via `Security.CertificateSubject`. Certificate stores use the directory-based store type under the configured `Security.PkiRootPath` (default `%LOCALAPPDATA%\OPC Foundation\pki\`):
|
||||
Certificate stores default to `%LOCALAPPDATA%\OPC Foundation\pki\` (directory-based):
|
||||
|
||||
| Store | Path suffix |
|
||||
|-------|-------------|
|
||||
|---|---|
|
||||
| Own | `pki/own` |
|
||||
| Trusted issuers | `pki/issuer` |
|
||||
| Trusted peers | `pki/trusted` |
|
||||
| Rejected | `pki/rejected` |
|
||||
|
||||
`AutoAcceptUntrustedCertificates` is controlled by `Security.AutoAcceptClientCertificates` (default `true`). Set to `false` in production to enforce client certificate trust. When `RejectSHA1Certificates` is `true` (default), client certificates signed with SHA-1 are rejected. Certificate validation events are logged for visibility into accepted and rejected client connections.
|
||||
|
||||
## Server class hierarchy
|
||||
|
||||
### LmxOpcUaServer extends StandardServer
|
||||
|
||||
`LmxOpcUaServer` inherits from the OPC Foundation `StandardServer` base class and overrides two methods:
|
||||
|
||||
- **`CreateMasterNodeManager`** -- Instantiates `LmxNodeManager` with the Galaxy namespace URI, the `IMxAccessClient` for runtime I/O, performance metrics, and an optional `IHistorianDataSource` (supplied by the runtime-loaded historian plugin, see [Historical Data Access](HistoricalDataAccess.md)). The node manager is wrapped in a `MasterNodeManager` with no additional core node managers.
|
||||
- **`OnServerStarted`** -- Configures redundancy, history capabilities, and server capabilities at startup. Called after the server is fully initialized.
|
||||
- **`LoadServerProperties`** -- Returns server metadata: manufacturer `ZB MOM`, product `LmxOpcUa Server`, and the assembly version as the software version.
|
||||
|
||||
### ServerCapabilities
|
||||
|
||||
`ConfigureServerCapabilities` populates the `ServerCapabilities` node at startup:
|
||||
|
||||
- **ServerProfileArray** -- `StandardUA2017`
|
||||
- **LocaleIdArray** -- `en`
|
||||
- **MinSupportedSampleRate** -- 100ms
|
||||
- **MaxBrowseContinuationPoints** -- 100
|
||||
- **MaxHistoryContinuationPoints** -- 100
|
||||
- **MaxArrayLength** -- 65535
|
||||
- **MaxStringLength / MaxByteStringLength** -- 4MB
|
||||
- **OperationLimits** -- 1000 nodes per Read/Write/Browse/RegisterNodes/TranslateBrowsePaths/MonitoredItems/HistoryRead; 0 for MethodCall/NodeManagement/HistoryUpdate (not supported)
|
||||
- **ServerDiagnostics.EnabledFlag** -- `true` (SDK tracks session/subscription counts automatically)
|
||||
|
||||
### Session tracking
|
||||
|
||||
`LmxOpcUaServer` exposes `ActiveSessionCount` by querying `ServerInternal.SessionManager.GetSessions().Count`. `OpcUaServerHost` surfaces this for status reporting.
|
||||
|
||||
## Startup and Shutdown
|
||||
|
||||
`OpcUaServerHost.StartAsync` performs the following sequence:
|
||||
|
||||
1. Build `ApplicationConfiguration` programmatically
|
||||
2. Validate the configuration via `appConfig.Validate(ApplicationType.Server)`
|
||||
3. Create `ApplicationInstance` and check/create the application certificate
|
||||
4. Instantiate `LmxOpcUaServer` and start it via `ApplicationInstance.Start`
|
||||
|
||||
`OpcUaServerHost.Stop` calls `_server.Stop()` and nulls both the server and application instance references. The class implements `IDisposable`, delegating to `Stop`.
|
||||
`Security.AutoAcceptClientCertificates` (default `true`) and `RejectSHA1Certificates` (default `true`) are honored. The server certificate is always created — even for `None`-only deployments — because `UserName` token encryption needs it.
|
||||
|
||||
## Key source files
|
||||
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OpcUaServerHost.cs` -- Application lifecycle and programmatic configuration
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LmxOpcUaServer.cs` -- StandardServer subclass and node manager creation
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/OpcUa/SecurityProfileResolver.cs` -- Profile-name to ServerSecurityPolicy mapping
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/Configuration/OpcUaConfiguration.cs` -- Configuration POCO
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecurityProfileConfiguration.cs` -- Security configuration POCO
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs` — `StandardServer` subclass + `ImpersonateUser` hook
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs` — per-driver `CustomNodeManager2` + dispatch surface
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs` — programmatic `ApplicationConfiguration` + lifecycle
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs` — driver registration
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — Polly pipeline entry point
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — Phase 6.2 permission trie + evaluator
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs` — stack-to-evaluator bridge
|
||||
|
||||
83
docs/README.md
Normal file
83
docs/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# OtOpcUa documentation
|
||||
|
||||
Two tiers of documentation live here:
|
||||
|
||||
- **Current reference** at the top level (`docs/*.md`) — describes what's shipped today. Start here for operator + integrator reference.
|
||||
- **Implementation history + design notes** at `docs/v2/*.md` — the authoritative plan + decision log the current reference is built from. Start here when you need the *why* behind an architectural choice, or when a top-level doc says "see plan.md § X".
|
||||
|
||||
The project was originally called **LmxOpcUa** (a single-driver Galaxy/MXAccess OPC UA server) and has since become **OtOpcUa**, a multi-driver OPC UA server platform. Any lingering `LmxOpcUa`-string in a path you see in docs is a deliberate residual (executable name `lmxopcua-cli`, client PKI folder `{LocalAppData}/LmxOpcUaClient/`) — fixing those requires migration shims + is tracked as follow-ups.
|
||||
|
||||
## Platform overview
|
||||
|
||||
- **Core** owns the OPC UA stack, address space, session/security/subscription machinery.
|
||||
- **Drivers** plug in via capability interfaces in `ZB.MOM.WW.OtOpcUa.Core.Abstractions`: `IDriver`, `IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`, `IAlarmSource`, `IHistoryProvider`, `IPerCallHostResolver`. Each driver opts into whichever it supports.
|
||||
- **Server** is the OPC UA endpoint process (net10, x64). Hosts every driver except Galaxy in-process; talks to Galaxy via a named pipe because MXAccess COM is 32-bit-only.
|
||||
- **Admin** is the Blazor Server operator UI (net10, x64). Owns the Config DB draft/publish flow, ACL + role-grant authoring, fleet status + `/metrics` scrape endpoint.
|
||||
- **Galaxy.Host** is a .NET Framework 4.8 x86 Windows service that wraps MXAccess COM on an STA thread for the Galaxy driver.
|
||||
|
||||
## Where to find what
|
||||
|
||||
### Architecture + data-path reference
|
||||
|
||||
| Doc | Covers |
|
||||
|-----|--------|
|
||||
| [OpcUaServer.md](OpcUaServer.md) | Top-level server architecture — Core, driver dispatch, Config DB, generations |
|
||||
| [AddressSpace.md](AddressSpace.md) | `GenericDriverNodeManager` + `ITagDiscovery` + `IAddressSpaceBuilder` |
|
||||
| [ReadWriteOperations.md](ReadWriteOperations.md) | OPC UA Read/Write → `CapabilityInvoker` → `IReadable`/`IWritable` |
|
||||
| [Subscriptions.md](Subscriptions.md) | Monitored items → `ISubscribable` + per-driver subscription refcount |
|
||||
| [AlarmTracking.md](AlarmTracking.md) | `IAlarmSource` + `AlarmSurfaceInvoker` + OPC UA alarm conditions |
|
||||
| [DataTypeMapping.md](DataTypeMapping.md) | Per-driver `DriverAttributeInfo` → OPC UA variable types |
|
||||
| [IncrementalSync.md](IncrementalSync.md) | Address-space rebuild on redeploy + `sp_ComputeGenerationDiff` |
|
||||
| [HistoricalDataAccess.md](HistoricalDataAccess.md) | `IHistoryProvider` as a per-driver optional capability |
|
||||
|
||||
### Drivers
|
||||
|
||||
| Doc | Covers |
|
||||
|-----|--------|
|
||||
| [drivers/README.md](drivers/README.md) | Index of the seven shipped drivers + capability matrix |
|
||||
| [drivers/Galaxy.md](drivers/Galaxy.md) | Galaxy driver — MXAccess bridge, Host/Proxy split, named-pipe IPC |
|
||||
| [drivers/Galaxy-Repository.md](drivers/Galaxy-Repository.md) | Galaxy-specific discovery via the ZB SQL database |
|
||||
|
||||
For Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS / OPC UA Client specifics, see [v2/driver-specs.md](v2/driver-specs.md).
|
||||
|
||||
### Operational
|
||||
|
||||
| Doc | Covers |
|
||||
|-----|--------|
|
||||
| [Configuration.md](Configuration.md) | appsettings bootstrap + Config DB + Admin UI draft/publish |
|
||||
| [security.md](security.md) | Transport security profiles, LDAP auth, ACL trie, role grants, OTOPCUA0001 analyzer |
|
||||
| [Redundancy.md](Redundancy.md) | `RedundancyCoordinator`, `ServiceLevelCalculator`, apply-lease, Prometheus metrics |
|
||||
| [ServiceHosting.md](ServiceHosting.md) | Three-process deploy (Server + Admin + Galaxy.Host) install/uninstall |
|
||||
| [StatusDashboard.md](StatusDashboard.md) | Pointer — superseded by [v2/admin-ui.md](v2/admin-ui.md) |
|
||||
|
||||
### Client tooling
|
||||
|
||||
| Doc | Covers |
|
||||
|-----|--------|
|
||||
| [Client.CLI.md](Client.CLI.md) | `lmxopcua-cli` — command-line client |
|
||||
| [Client.UI.md](Client.UI.md) | Avalonia desktop client |
|
||||
|
||||
### Requirements
|
||||
|
||||
| Doc | Covers |
|
||||
|-----|--------|
|
||||
| [reqs/HighLevelReqs.md](reqs/HighLevelReqs.md) | HLRs — numbered system-level requirements |
|
||||
| [reqs/OpcUaServerReqs.md](reqs/OpcUaServerReqs.md) | OPC UA server-layer reqs |
|
||||
| [reqs/ServiceHostReqs.md](reqs/ServiceHostReqs.md) | Per-process hosting reqs |
|
||||
| [reqs/ClientRequirements.md](reqs/ClientRequirements.md) | Client CLI + UI reqs |
|
||||
| [reqs/GalaxyRepositoryReqs.md](reqs/GalaxyRepositoryReqs.md) | Galaxy-scoped repository reqs |
|
||||
| [reqs/MxAccessClientReqs.md](reqs/MxAccessClientReqs.md) | Galaxy-scoped MXAccess reqs |
|
||||
| [reqs/StatusDashboardReqs.md](reqs/StatusDashboardReqs.md) | Pointer — superseded by Admin UI |
|
||||
|
||||
## Implementation history (`docs/v2/`)
|
||||
|
||||
Design decisions + phase plans + execution notes. Load-bearing cross-references from the top-level docs:
|
||||
|
||||
- [v2/plan.md](v2/plan.md) — authoritative v2 vision doc + numbered decision log (referenced as "decision #N" elsewhere)
|
||||
- [v2/admin-ui.md](v2/admin-ui.md) — Admin UI spec
|
||||
- [v2/acl-design.md](v2/acl-design.md) — data-plane ACL + permission-trie design (Phase 6.2)
|
||||
- [v2/config-db-schema.md](v2/config-db-schema.md) — Config DB schema reference
|
||||
- [v2/driver-specs.md](v2/driver-specs.md) — per-driver addressing + quirks for every shipped protocol
|
||||
- [v2/dev-environment.md](v2/dev-environment.md) — dev-box bootstrap
|
||||
- [v2/test-data-sources.md](v2/test-data-sources.md) — integration-test simulator matrix (includes the pinned libplctag `ab_server` version for AB CIP tests)
|
||||
- [v2/implementation/phase-*-*.md](v2/implementation/) — per-phase execution plans with exit-gate evidence
|
||||
@@ -1,99 +1,57 @@
|
||||
# Read/Write Operations
|
||||
|
||||
`LmxNodeManager` overrides the OPC UA `Read` and `Write` methods to translate client requests into MXAccess runtime calls. Each override resolves the OPC UA `NodeId` to a Galaxy tag reference, performs the I/O through `IMxAccessClient`, and returns the result with appropriate status codes.
|
||||
`DriverNodeManager` (`src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs`) wires the OPC UA stack's per-variable `OnReadValue` and `OnWriteValue` hooks to each driver's `IReadable` and `IWritable` capabilities. Every dispatch flows through `CapabilityInvoker` so the Polly pipeline (retry / timeout / breaker / bulkhead) applies uniformly across Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client drivers.
|
||||
|
||||
## Read Override
|
||||
## OnReadValue
|
||||
|
||||
The `Read` override in `LmxNodeManager` intercepts value attribute reads for nodes in the Galaxy namespace.
|
||||
The hook is registered on every `BaseDataVariableState` created by the `IAddressSpaceBuilder.Variable(...)` call during discovery. When the stack dispatches a Read for a node in this namespace:
|
||||
|
||||
### Resolution flow
|
||||
1. If the driver does not implement `IReadable`, the hook returns `BadNotReadable`.
|
||||
2. The node's `NodeId.Identifier` is used directly as the driver-side full reference — it matches `DriverAttributeInfo.FullName` registered at discovery time.
|
||||
3. (Phase 6.2) If an `AuthorizationGate` + `NodeScopeResolver` are wired, the gate is consulted first via `IsAllowed(identity, OpcUaOperation.Read, scope)`. A denied read never hits the driver.
|
||||
4. The call is wrapped by `_invoker.ExecuteAsync(DriverCapability.Read, ResolveHostFor(fullRef), …)`. The resolved host is `IPerCallHostResolver.ResolveHost(fullRef)` for multi-host drivers; single-host drivers fall back to `DriverInstanceId` (decision #144).
|
||||
5. The first `DataValueSnapshot` from the batch populates the outgoing `value` / `statusCode` / `timestamp`. An empty batch surfaces `BadNoData`; any exception surfaces `BadInternalError`.
|
||||
|
||||
1. The base class `Read` runs first, handling non-value attributes (DisplayName, DataType, etc.) through the standard node manager.
|
||||
2. For each `ReadValueId` where `AttributeId == Attributes.Value`, the override checks whether the node belongs to this namespace (`NamespaceIndex` match).
|
||||
3. The string-typed `NodeId.Identifier` is looked up in `_nodeIdToTagReference` to find the corresponding `FullTagReference` (e.g., `DelmiaReceiver_001.DownloadPath`).
|
||||
4. `_mxAccessClient.ReadAsync(tagRef)` retrieves the current value, timestamp, and quality from MXAccess. The async call is synchronously awaited because the OPC UA SDK `Read` override is synchronous.
|
||||
5. The returned `Vtq` is converted to a `DataValue` via `CreatePublishedDataValue`, which normalizes array values through `NormalizePublishedValue` (substituting a default typed array when the value is null for array nodes).
|
||||
6. On success, `errors[i]` is set to `ServiceResult.Good`. On exception, the error is set to `BadInternalError`.
|
||||
The hook is synchronous — the async invoker call is bridged with `AsTask().GetAwaiter().GetResult()` because the OPC UA SDK's value-hook signature is sync. Idempotent-by-construction reads mean this bridge is safe to retry inside the Polly pipeline.
|
||||
|
||||
```csharp
|
||||
if (_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
|
||||
{
|
||||
var vtq = _mxAccessClient.ReadAsync(tagRef).GetAwaiter().GetResult();
|
||||
results[i] = CreatePublishedDataValue(tagRef, vtq);
|
||||
errors[i] = ServiceResult.Good;
|
||||
}
|
||||
```
|
||||
## OnWriteValue
|
||||
|
||||
## Write Override
|
||||
`OnWriteValue` follows the same shape with two additional concerns: authorization and idempotence.
|
||||
|
||||
The `Write` override follows a similar pattern but includes access-level enforcement and array element write support.
|
||||
### Authorization (two layers)
|
||||
|
||||
### Access level check
|
||||
1. **SecurityClassification gate.** Every variable stores its `SecurityClassification` in `_securityByFullRef` at registration time (populated from `DriverAttributeInfo.SecurityClass`). `WriteAuthzPolicy.IsAllowed(classification, userRoles)` runs first, consulting the session's roles via `context.UserIdentity is IRoleBearer`. `FreeAccess` passes anonymously, `ViewOnly` denies everyone, and `Operate / Tune / Configure / SecuredWrite / VerifiedWrite` require `WriteOperate / WriteTune / WriteConfigure` roles respectively. Denial returns `BadUserAccessDenied` without consulting the driver — drivers never enforce ACLs themselves; they only report classification as discovery metadata (feedback `feedback_acl_at_server_layer.md`).
|
||||
2. **Phase 6.2 permission-trie gate.** When `AuthorizationGate` is wired, it re-runs with the operation derived from `WriteAuthzPolicy.ToOpcUaOperation(classification)`. The gate consults the per-cluster permission trie loaded from `NodeAcl` rows, enforcing fine-grained per-tag ACLs on top of the role-based classification policy. See `docs/v2/acl-design.md`.
|
||||
|
||||
The base class `Write` runs first and sets `BadNotWritable` for nodes whose `AccessLevel` does not include `CurrentWrite`. The override skips these nodes:
|
||||
### Dispatch
|
||||
|
||||
```csharp
|
||||
if (errors[i] != null && errors[i].StatusCode == StatusCodes.BadNotWritable)
|
||||
continue;
|
||||
```
|
||||
`_invoker.ExecuteWriteAsync(host, isIdempotent, callSite, …)` honors the `WriteIdempotentAttribute` semantics per decisions #44-45 and #143:
|
||||
|
||||
The `AccessLevel` is set during node creation based on `SecurityClassificationMapper.IsWritable(attr.SecurityClassification)`. Read-only Galaxy attributes (e.g., security classification `FreeRead`) get `AccessLevels.CurrentRead` only.
|
||||
- `isIdempotent = true` (tag flagged `WriteIdempotent` in the Config DB) → runs through the standard `DriverCapability.Write` pipeline; retry may apply per the tier configuration.
|
||||
- `isIdempotent = false` (default) → the invoker builds a one-off pipeline with `RetryCount = 0`. A timeout may fire after the device already accepted the pulse / alarm-ack / counter-increment; replay is the caller's decision, not the server's.
|
||||
|
||||
### Write flow
|
||||
The `_writeIdempotentByFullRef` lookup is populated at discovery time from the `DriverAttributeInfo.WriteIdempotent` field.
|
||||
|
||||
1. The `NodeId` is resolved to a tag reference via `_nodeIdToTagReference`.
|
||||
2. The raw value is extracted from `writeValue.Value.WrappedValue.Value`.
|
||||
3. If the write includes an `IndexRange` (array element write), `TryApplyArrayElementWrite` handles the merge before sending the full array to MXAccess.
|
||||
4. `_mxAccessClient.WriteAsync(tagRef, value)` sends the value to the Galaxy runtime.
|
||||
5. On success, `PublishLocalWrite` updates the in-memory node immediately so subscribed clients see the change without waiting for the next MXAccess data change callback.
|
||||
### Per-write status
|
||||
|
||||
### Array element writes via IndexRange
|
||||
`IWritable.WriteAsync` returns `IReadOnlyList<WriteResult>` — one numeric `StatusCode` per requested write. A non-zero code is surfaced directly to the client; exceptions become `BadInternalError`. The OPC UA stack's pattern of batching per-service is preserved through the full chain.
|
||||
|
||||
`TryApplyArrayElementWrite` supports writing individual elements of an array attribute. MXAccess does not support element-level writes, so the method performs a read-modify-write:
|
||||
## Array element writes
|
||||
|
||||
1. Parse the `IndexRange` string as a zero-based integer index. Return `BadIndexRangeInvalid` if parsing fails or the index is negative.
|
||||
2. Read the current array value from MXAccess via `ReadAsync`.
|
||||
3. Clone the array and set the element at the target index.
|
||||
4. `NormalizeIndexedWriteValue` unwraps single-element arrays (OPC UA clients sometimes wrap a scalar in a one-element array).
|
||||
5. `ConvertArrayElementValue` coerces the value to the array's element type using `Convert.ChangeType`, handling null values by substituting the type's default.
|
||||
6. The full modified array is written back to MXAccess as a single `WriteAsync` call.
|
||||
Array-element writes via OPC UA `IndexRange` are driver-specific. The OPC UA stack hands the dispatch an unwrapped `NumericRange` on the `indexRange` parameter of `OnWriteValue`; `DriverNodeManager` passes the full `value` object to `IWritable.WriteAsync` and the driver decides whether to support partial writes. Galaxy performs a read-modify-write inside the Galaxy driver (MXAccess has no element-level writes); other drivers generally accept only full-array writes today.
|
||||
|
||||
```csharp
|
||||
var nextArray = (Array)currentArray.Clone();
|
||||
nextArray.SetValue(ConvertArrayElementValue(normalizedValue, elementType), index);
|
||||
updatedArray = nextArray;
|
||||
```
|
||||
## HistoryRead
|
||||
|
||||
### Role-based write enforcement
|
||||
`DriverNodeManager.HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`, and `HistoryReadEvents` route through the driver's `IHistoryProvider` capability with `DriverCapability.HistoryRead`. Drivers without `IHistoryProvider` surface `BadHistoryOperationUnsupported` per node. See `docs/HistoricalDataAccess.md`.
|
||||
|
||||
When `AnonymousCanWrite` is `false` in the `Authentication` configuration, the write override enforces role-based access control before dispatching to MXAccess. The check order is:
|
||||
## Failure isolation
|
||||
|
||||
1. The base class `Write` runs first, enforcing `AccessLevel`. Nodes without `CurrentWrite` get `BadNotWritable` and the override skips them.
|
||||
2. The override checks whether the node is in the Galaxy namespace. Non-namespace nodes are skipped.
|
||||
3. If `AnonymousCanWrite` is `false`, the override inspects `context.OperationContext.Session` for `GrantedRoleIds`. If the session does not hold `WellKnownRole_AuthenticatedUser`, the error is set to `BadUserAccessDenied` and the write is rejected.
|
||||
4. If the role check passes (or `AnonymousCanWrite` is `true`), the write proceeds to MXAccess.
|
||||
Per decision #12, exceptions in the driver's capability call are logged and converted to a per-node `BadInternalError` — they never unwind into the master node manager. This keeps one driver's outage from disrupting sibling drivers in the same server process.
|
||||
|
||||
The existing security classification enforcement (ReadOnly nodes getting `BadNotWritable` via `AccessLevel`) still applies first and takes precedence over the role check.
|
||||
## Key source files
|
||||
|
||||
## Value Type Conversion
|
||||
|
||||
`CreatePublishedDataValue` wraps the conversion pipeline. `NormalizePublishedValue` checks whether the tag is an array type with a declared `ArrayDimension` and substitutes a default typed array (via `CreateDefaultArrayValue`) when the raw value is null. This prevents OPC UA clients from receiving a null variant for array nodes, which violates the specification for nodes declared with `ValueRank.OneDimension`.
|
||||
|
||||
`CreateDefaultArrayValue` uses `MxDataTypeMapper.MapToClrType` to determine the CLR element type, then creates an `Array.CreateInstance` of the declared length. String arrays are initialized with `string.Empty` elements rather than null.
|
||||
|
||||
## PublishLocalWrite
|
||||
|
||||
After a successful write, `PublishLocalWrite` updates the variable node in memory without waiting for the MXAccess `OnDataChange` callback to arrive:
|
||||
|
||||
```csharp
|
||||
private void PublishLocalWrite(string tagRef, object? value)
|
||||
{
|
||||
var dataValue = CreatePublishedDataValue(tagRef, Vtq.Good(value));
|
||||
variable.Value = dataValue.Value;
|
||||
variable.StatusCode = dataValue.StatusCode;
|
||||
variable.Timestamp = dataValue.SourceTimestamp;
|
||||
variable.ClearChangeMasks(SystemContext, false);
|
||||
}
|
||||
```
|
||||
|
||||
`ClearChangeMasks` notifies the OPC UA framework that the node value has changed, which triggers data change notifications to any active monitored items. Without this call, subscribed clients would only see the update when the next MXAccess data change event arrives, which could be delayed depending on the subscription interval.
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs` — `OnReadValue` / `OnWriteValue` hooks
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Server/Security/WriteAuthzPolicy.cs` — classification-to-role policy
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs` — Phase 6.2 trie gate
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — `ExecuteAsync` / `ExecuteWriteAsync`
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs`, `IWritable.cs`, `WriteIdempotentAttribute.cs`
|
||||
|
||||
@@ -2,189 +2,102 @@
|
||||
|
||||
## Overview
|
||||
|
||||
LmxOpcUa supports OPC UA **non-transparent redundancy** in Warm or Hot mode. In a non-transparent redundancy deployment, two independent server instances run side by side. Both connect to the same Galaxy repository database and the same MXAccess runtime, but each maintains its own OPC UA sessions and subscriptions. Clients discover the redundant set through the `ServerUriArray` exposed in each server's address space and are responsible for managing failover between the two endpoints.
|
||||
OtOpcUa supports OPC UA **non-transparent** warm/hot redundancy. Two (or more) OtOpcUa Server processes run side-by-side, share the same Config DB, the same driver backends (Galaxy ZB, MXAccess runtime, remote PLCs), and advertise the same OPC UA node tree. Each process owns a distinct `ApplicationUri`; OPC UA clients see both endpoints via the standard `ServerUriArray` and pick one based on the `ServiceLevel` that each server publishes.
|
||||
|
||||
When redundancy is disabled (the default), the server reports `RedundancySupport.None` and a fixed `ServiceLevel` of 255.
|
||||
The redundancy surface lives in `src/ZB.MOM.WW.OtOpcUa.Server/Redundancy/`:
|
||||
|
||||
## Namespace vs Application Identity
|
||||
|
||||
Both servers in the redundant set share the same **namespace URI** so that clients see identical node IDs regardless of which instance they are connected to. The namespace URI follows the pattern `urn:{GalaxyName}:LmxOpcUa` (e.g., `urn:ZB:LmxOpcUa`).
|
||||
|
||||
The **ApplicationUri**, on the other hand, must be unique per instance. This is how the OPC UA stack and clients distinguish one server from the other within the redundant set. Each instance sets its own ApplicationUri via the `OpcUa.ApplicationUri` configuration property (e.g., `urn:localhost:LmxOpcUa:instance1` and `urn:localhost:LmxOpcUa:instance2`).
|
||||
|
||||
When redundancy is disabled, `ApplicationUri` defaults to `urn:{GalaxyName}:LmxOpcUa` if left null.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Redundancy Section
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `Enabled` | bool | `false` | Enables non-transparent redundancy. When false, the server reports `RedundancySupport.None` and `ServiceLevel = 255`. |
|
||||
| `Mode` | string | `"Warm"` | The redundancy mode advertised to clients. Valid values: `Warm`, `Hot`. |
|
||||
| `Role` | string | `"Primary"` | This instance's role in the redundant pair. Valid values: `Primary`, `Secondary`. The Primary advertises a higher ServiceLevel than the Secondary when both are healthy. |
|
||||
| `ServerUris` | string[] | `[]` | The ApplicationUri values of all servers in the redundant set. Must include this instance's own `OpcUa.ApplicationUri`. Should contain at least 2 entries. |
|
||||
| `ServiceLevelBase` | int | `200` | The base ServiceLevel when the server is fully healthy. Valid range: 1-255. The Secondary automatically receives `ServiceLevelBase - 50`. |
|
||||
|
||||
### OpcUa.ApplicationUri
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ApplicationUri` | string | `null` | Explicit application URI for this server instance. When null, defaults to `urn:{GalaxyName}:LmxOpcUa`. **Required when redundancy is enabled** -- each instance needs a unique identity. |
|
||||
|
||||
## ServiceLevel Computation
|
||||
|
||||
ServiceLevel is a standard OPC UA diagnostic value (0-255) that indicates server health. Clients in a redundant deployment should prefer the server advertising the highest ServiceLevel.
|
||||
|
||||
**Baseline values:**
|
||||
|
||||
| Role | Baseline |
|
||||
| Class | Role |
|
||||
|---|---|
|
||||
| Primary | `ServiceLevelBase` (default 200) |
|
||||
| Secondary | `ServiceLevelBase - 50` (default 150) |
|
||||
| `RedundancyCoordinator` | Process-singleton; owns the current `RedundancyTopology` loaded from the `ClusterNode` table. `RefreshAsync` re-reads after `sp_PublishGeneration` so operator role swaps take effect without a process restart. CAS-style swap (`Interlocked.Exchange`) means readers always see a coherent snapshot. |
|
||||
| `RedundancyTopology` | Immutable `(ClusterId, Self, Peers, ServerUriArray, ValidityFlags)` snapshot. |
|
||||
| `ApplyLeaseRegistry` | Tracks in-progress `sp_PublishGeneration` apply leases keyed on `(ConfigGenerationId, PublishRequestId)`. `await using` the disposable scope guarantees every exit path (success / exception / cancellation) decrements the lease; a stale-lease watchdog force-closes any lease older than `ApplyMaxDuration` (default 10 minutes) so a crashed publisher can't pin the node at `PrimaryMidApply`. |
|
||||
| `PeerReachabilityTracker` | Maintains last-known reachability for each peer node over two independent probes — OPC UA ping and HTTP `/healthz`. Both must succeed for `peerReachable = true`. |
|
||||
| `RecoveryStateManager` | Gates transitions out of the `Recovering*` bands; requires dwell + publish-witness satisfaction before allowing a return to nominal. |
|
||||
| `ServiceLevelCalculator` | Pure function `(role, selfHealthy, peerUa, peerHttp, applyInProgress, recoveryDwellMet, topologyValid, operatorMaintenance) → byte`. |
|
||||
| `RedundancyStatePublisher` | Orchestrates inputs into the calculator, pushes the resulting byte to the OPC UA `ServiceLevel` variable via an edge-triggered `OnStateChanged` event, and fires `OnServerUriArrayChanged` when the topology's `ServerUriArray` shifts. |
|
||||
|
||||
**Penalties applied to the baseline:**
|
||||
## Data model
|
||||
|
||||
| Condition | Penalty |
|
||||
Per-node redundancy state lives in the Config DB `ClusterNode` table (`src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs`):
|
||||
|
||||
| Column | Role |
|
||||
|---|---|
|
||||
| MXAccess disconnected | -100 |
|
||||
| Galaxy DB unreachable | -50 |
|
||||
| Both MXAccess and DB down | ServiceLevel forced to 0 |
|
||||
| `NodeId` | Unique node identity; matches `Node:NodeId` in the server's bootstrap `appsettings.json`. |
|
||||
| `ClusterId` | Foreign key into `ServerCluster`. |
|
||||
| `RedundancyRole` | `Primary`, `Secondary`, or `Standalone` (`RedundancyRole` enum in `Configuration/Enums`). |
|
||||
| `ServiceLevelBase` | Per-node base value used to bias nominal ServiceLevel output. |
|
||||
| `ApplicationUri` | Unique-per-node OPC UA ApplicationUri advertised in endpoint descriptions. |
|
||||
|
||||
The final value is clamped to the range 0-255.
|
||||
`ServerUriArray` is derived from the set of peer `ApplicationUri` values at topology-load time and republished when the topology changes.
|
||||
|
||||
**Examples (with default ServiceLevelBase = 200):**
|
||||
## ServiceLevel matrix
|
||||
|
||||
| Scenario | Primary | Secondary |
|
||||
`ServiceLevelCalculator` produces one of the following bands (see `ServiceLevelBand` enum in the same file):
|
||||
|
||||
| Band | Byte | Meaning |
|
||||
|---|---|---|
|
||||
| Both healthy | 200 | 150 |
|
||||
| MXAccess down | 100 | 50 |
|
||||
| DB down | 150 | 100 |
|
||||
| Both down | 0 | 0 |
|
||||
| `Maintenance` | 0 | Operator-declared maintenance. |
|
||||
| `NoData` | 1 | Self-reported unhealthy (`/healthz` fails). |
|
||||
| `InvalidTopology` | 2 | More than one Primary detected; both nodes self-demote. |
|
||||
| `RecoveringBackup` | 30 | Backup post-fault, dwell not met. |
|
||||
| `BackupMidApply` | 50 | Backup inside a publish-apply window. |
|
||||
| `IsolatedBackup` | 80 | Primary unreachable; Backup says "take over if asked" — does **not** auto-promote (non-transparent model). |
|
||||
| `AuthoritativeBackup` | 100 | Backup nominal. |
|
||||
| `RecoveringPrimary` | 180 | Primary post-fault, dwell not met. |
|
||||
| `PrimaryMidApply` | 200 | Primary inside a publish-apply window. |
|
||||
| `IsolatedPrimary` | 230 | Primary with unreachable peer, retains authority. |
|
||||
| `AuthoritativePrimary` | 255 | Primary nominal. |
|
||||
|
||||
## Two-Instance Deployment
|
||||
The reserved bands (0 Maintenance, 1 NoData, 2 InvalidTopology) take precedence over operational states per OPC UA Part 5 §6.3.34. Operational values occupy 2..255 so spec-compliant clients that treat "<3 = unhealthy" keep working.
|
||||
|
||||
When deploying a redundant pair, the following configuration properties must differ between the two instances. All other settings (GalaxyName, ConnectionString, etc.) are shared.
|
||||
Standalone nodes (single-instance deployments) report `AuthoritativePrimary` when healthy and `PrimaryMidApply` during publish.
|
||||
|
||||
| Property | Instance 1 (Primary) | Instance 2 (Secondary) |
|
||||
|---|---|---|
|
||||
| `OpcUa.Port` | 4840 | 4841 |
|
||||
| `OpcUa.ServerName` | `LmxOpcUa-1` | `LmxOpcUa-2` |
|
||||
| `OpcUa.ApplicationUri` | `urn:localhost:LmxOpcUa:instance1` | `urn:localhost:LmxOpcUa:instance2` |
|
||||
| `Dashboard.Port` | 8081 | 8082 |
|
||||
| `MxAccess.ClientName` | `LmxOpcUa-1` | `LmxOpcUa-2` |
|
||||
| `Redundancy.Role` | `Primary` | `Secondary` |
|
||||
## Publish fencing and split-brain prevention
|
||||
|
||||
### Instance 1 -- Primary (appsettings.json)
|
||||
Any Admin-triggered `sp_PublishGeneration` acquires an apply lease through `ApplyLeaseRegistry.BeginApplyLease`. While the lease is held:
|
||||
|
||||
```json
|
||||
{
|
||||
"OpcUa": {
|
||||
"Port": 4840,
|
||||
"ServerName": "LmxOpcUa-1",
|
||||
"GalaxyName": "ZB",
|
||||
"ApplicationUri": "urn:localhost:LmxOpcUa:instance1"
|
||||
},
|
||||
"MxAccess": {
|
||||
"ClientName": "LmxOpcUa-1"
|
||||
},
|
||||
"Dashboard": {
|
||||
"Port": 8081
|
||||
},
|
||||
"Redundancy": {
|
||||
"Enabled": true,
|
||||
"Mode": "Warm",
|
||||
"Role": "Primary",
|
||||
"ServerUris": [
|
||||
"urn:localhost:LmxOpcUa:instance1",
|
||||
"urn:localhost:LmxOpcUa:instance2"
|
||||
],
|
||||
"ServiceLevelBase": 200
|
||||
}
|
||||
}
|
||||
```
|
||||
- The calculator reports `PrimaryMidApply` / `BackupMidApply` — clients see the band shift and cut over to the unaffected peer rather than racing against a half-applied generation.
|
||||
- `RedundancyCoordinator.RefreshAsync` is called at the end of the apply window so the post-publish topology becomes visible exactly once, atomically.
|
||||
- The watchdog force-closes any lease older than `ApplyMaxDuration`; a stuck publisher therefore cannot strand a node at `PrimaryMidApply`.
|
||||
|
||||
### Instance 2 -- Secondary (appsettings.json)
|
||||
Because role transitions are **operator-driven** (write `RedundancyRole` in the Config DB + publish), the Backup never auto-promotes. An `IsolatedBackup` at 80 is the signal that the operator should intervene; auto-failover is intentionally out of scope for the non-transparent model (decision #154).
|
||||
|
||||
```json
|
||||
{
|
||||
"OpcUa": {
|
||||
"Port": 4841,
|
||||
"ServerName": "LmxOpcUa-2",
|
||||
"GalaxyName": "ZB",
|
||||
"ApplicationUri": "urn:localhost:LmxOpcUa:instance2"
|
||||
},
|
||||
"MxAccess": {
|
||||
"ClientName": "LmxOpcUa-2"
|
||||
},
|
||||
"Dashboard": {
|
||||
"Port": 8082
|
||||
},
|
||||
"Redundancy": {
|
||||
"Enabled": true,
|
||||
"Mode": "Warm",
|
||||
"Role": "Secondary",
|
||||
"ServerUris": [
|
||||
"urn:localhost:LmxOpcUa:instance1",
|
||||
"urn:localhost:LmxOpcUa:instance2"
|
||||
],
|
||||
"ServiceLevelBase": 200
|
||||
}
|
||||
}
|
||||
```
|
||||
## Metrics
|
||||
|
||||
## CLI `redundancy` Command
|
||||
`RedundancyMetrics` in `src/ZB.MOM.WW.OtOpcUa.Admin/Services/RedundancyMetrics.cs` registers the `ZB.MOM.WW.OtOpcUa.Redundancy` meter on the Admin process. Instruments:
|
||||
|
||||
The Client CLI includes a `redundancy` command that reads the redundancy state from a running server.
|
||||
| Name | Kind | Tags | Description |
|
||||
|---|---|---|---|
|
||||
| `otopcua.redundancy.role_transition` | Counter<long> | `cluster.id`, `node.id`, `from_role`, `to_role` | Incremented every time `FleetStatusPoller` observes a `RedundancyRole` change on a `ClusterNode` row. |
|
||||
| `otopcua.redundancy.primary_count` | ObservableGauge<long> | `cluster.id` | Primary-role nodes per cluster — should be exactly 1 in nominal state. |
|
||||
| `otopcua.redundancy.secondary_count` | ObservableGauge<long> | `cluster.id` | Secondary-role nodes per cluster. |
|
||||
| `otopcua.redundancy.stale_count` | ObservableGauge<long> | `cluster.id` | Nodes whose `LastSeenAt` exceeded the stale threshold. |
|
||||
|
||||
```bash
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- redundancy -u opc.tcp://localhost:4840/LmxOpcUa
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- redundancy -u opc.tcp://localhost:4841/LmxOpcUa
|
||||
```
|
||||
Admin `Program.cs` wires OpenTelemetry to the Prometheus exporter when `Metrics:Prometheus:Enabled=true` (default), exposing the meter under `/metrics`. The endpoint is intentionally unauthenticated — fleet conventions put it behind a reverse-proxy basic-auth gate if needed.
|
||||
|
||||
The command reads the following standard OPC UA nodes and displays their values:
|
||||
## Real-time notifications (Admin UI)
|
||||
|
||||
- **Redundancy Mode** -- from `Server_ServerRedundancy_RedundancySupport` (None, Warm, or Hot)
|
||||
- **Service Level** -- from `Server_ServiceLevel` (0-255)
|
||||
- **Server URIs** -- from `Server_ServerRedundancy_ServerUriArray` (list of ApplicationUri values in the redundant set)
|
||||
- **Application URI** -- from `Server_ServerArray` (this instance's ApplicationUri)
|
||||
`FleetStatusPoller` in `src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/` polls the `ClusterNode` table, records role transitions, updates `RedundancyMetrics.SetClusterCounts`, and pushes a `RoleChanged` SignalR event onto `FleetStatusHub` when a transition is observed. `RedundancyTab.razor` subscribes with `_hub.On<RoleChangedMessage>("RoleChanged", …)` so connected Admin sessions see role swaps the moment they happen.
|
||||
|
||||
Example output for a healthy Primary:
|
||||
## Configuring a redundant pair
|
||||
|
||||
```
|
||||
Redundancy Mode: Warm
|
||||
Service Level: 200
|
||||
Server URIs:
|
||||
- urn:localhost:LmxOpcUa:instance1
|
||||
- urn:localhost:LmxOpcUa:instance2
|
||||
Application URI: urn:localhost:LmxOpcUa:instance1
|
||||
```
|
||||
Redundancy is configured **in the Config DB, not appsettings.json**. The fields that must differ between the two instances:
|
||||
|
||||
The command also supports `--username`/`--password` and `--security` options for authenticated or encrypted connections.
|
||||
| Field | Location | Instance 1 | Instance 2 |
|
||||
|---|---|---|---|
|
||||
| `NodeId` | `appsettings.json` `Node:NodeId` (bootstrap) | `node-a` | `node-b` |
|
||||
| `ClusterNode.ApplicationUri` | Config DB | `urn:node-a:OtOpcUa` | `urn:node-b:OtOpcUa` |
|
||||
| `ClusterNode.RedundancyRole` | Config DB | `Primary` | `Secondary` |
|
||||
| `ClusterNode.ServiceLevelBase` | Config DB | typically 255 | typically 100 |
|
||||
|
||||
### Client Failover with `-F`
|
||||
Shared between instances: `ClusterId`, Config DB connection string, published generation, cluster-level ACLs, UNS hierarchy, driver instances.
|
||||
|
||||
All CLI commands support the `-F` / `--failover-urls` flag for automatic client-side failover. When provided, the CLI tries the primary endpoint first and falls back to the listed URLs if the primary is unreachable.
|
||||
Role swaps, stand-alone promotions, and base-level adjustments all happen through the Admin UI `RedundancyTab` — the operator edits the `ClusterNode` row in a draft generation and publishes. `RedundancyCoordinator.RefreshAsync` picks up the new topology without a process restart.
|
||||
|
||||
```bash
|
||||
# Connect with failover — uses secondary if primary is down
|
||||
dotnet run -- connect -u opc.tcp://localhost:4840/LmxOpcUa -F opc.tcp://localhost:4841/LmxOpcUa
|
||||
## Client-side failover
|
||||
|
||||
# Subscribe with live failover — reconnects to secondary if primary drops mid-stream
|
||||
dotnet run -- subscribe -u opc.tcp://localhost:4840/LmxOpcUa -F opc.tcp://localhost:4841/LmxOpcUa \
|
||||
-n "ns=1;s=TestMachine_001.MachineID"
|
||||
```
|
||||
The OtOpcUa Client CLI at `src/ZB.MOM.WW.OtOpcUa.Client.CLI` supports `-F` / `--failover-urls` for automatic client-side failover; for long-running subscriptions the CLI monitors session KeepAlive and reconnects to the next available server, recreating the subscription on the new endpoint. See [`Client.CLI.md`](Client.CLI.md) for the command reference.
|
||||
|
||||
For long-running commands (`subscribe`), the CLI monitors the session KeepAlive and automatically reconnects to the next available server when the current session drops. The subscription is re-created on the new server.
|
||||
## Depth reference
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Mismatched ServerUris between instances** -- Both instances must list the exact same set of ApplicationUri values in `Redundancy.ServerUris`. If they differ, clients may not discover the full redundant set. Check the startup log for the `Redundancy.ServerUris` line on each instance.
|
||||
|
||||
**ServiceLevel stuck at 255** -- This indicates redundancy is not enabled. When `Redundancy.Enabled` is false (the default), the server always reports `ServiceLevel = 255` and `RedundancySupport.None`. Verify that `Redundancy.Enabled` is set to `true` in the configuration and that the configuration section is correctly bound.
|
||||
|
||||
**ApplicationUri not set** -- The configuration validator rejects startup when redundancy is enabled but `OpcUa.ApplicationUri` is null or empty. Each instance must have a unique ApplicationUri. Check the error log for: `OpcUa.ApplicationUri must be set when redundancy is enabled`.
|
||||
|
||||
**Both servers report the same ServiceLevel** -- Verify that one instance has `Redundancy.Role` set to `Primary` and the other to `Secondary`. Both set to `Primary` (or both to `Secondary`) will produce identical baseline values, preventing clients from distinguishing the preferred server.
|
||||
|
||||
**ServerUriArray not readable** -- When `RedundancySupport` is `None` (redundancy disabled), the OPC UA SDK may not expose the `ServerUriArray` node or it may return an empty value. The CLI `redundancy` command handles this gracefully by catching the read error. Enable redundancy to populate this array.
|
||||
For the full decision trail and implementation plan — topology invariants, peer-probe cadence, recovery-dwell policy, compliance-script guard against enum-value drift — see `docs/v2/plan.md` §Phase 6.3.
|
||||
|
||||
@@ -2,189 +2,132 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The service runs as a Windows service or console application using TopShelf for lifecycle management. It targets .NET Framework 4.8 with an x86 (32-bit) platform target, which is required for MXAccess COM interop with the ArchestrA runtime DLLs.
|
||||
A production OtOpcUa deployment runs **three processes**, each with a distinct runtime, platform target, and install surface:
|
||||
|
||||
## TopShelf Configuration
|
||||
| Process | Project | Runtime | Platform | Responsibility |
|
||||
|---|---|---|---|---|
|
||||
| **OtOpcUa Server** | `src/ZB.MOM.WW.OtOpcUa.Server` | .NET 10 | x64 | Hosts the OPC UA endpoint; loads every non-Galaxy driver in-process; exposes `/healthz`. |
|
||||
| **OtOpcUa Admin** | `src/ZB.MOM.WW.OtOpcUa.Admin` | .NET 10 (ASP.NET Core / Blazor Server) | x64 | Operator UI for Config DB editing + fleet status, SignalR hubs (`FleetStatusHub`, `AlertHub`), Prometheus `/metrics`. |
|
||||
| **OtOpcUa Galaxy.Host** | `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host` | .NET Framework 4.8 | x86 (32-bit) | Hosts MXAccess COM on a dedicated STA thread with a Win32 message pump; exposes a named-pipe IPC surface consumed by `Driver.Galaxy.Proxy` inside the Server process. |
|
||||
|
||||
`Program.Main()` configures TopShelf to manage the `OpcUaService` lifecycle:
|
||||
The x86 / .NET Framework 4.8 constraint applies **only** to Galaxy.Host because the MXAccess toolkit DLLs (`Program Files (x86)\ArchestrA\Framework\bin`) are 32-bit-only COM. Every other driver (Modbus, S7, OpcUaClient, AbCip, AbLegacy, TwinCAT, FOCAS) runs in-process in the 64-bit Server.
|
||||
|
||||
## Server process
|
||||
|
||||
`src/ZB.MOM.WW.OtOpcUa.Server/Program.cs` uses the generic host:
|
||||
|
||||
```csharp
|
||||
var exitCode = HostFactory.Run(host =>
|
||||
{
|
||||
host.UseSerilog();
|
||||
|
||||
host.Service<OpcUaService>(svc =>
|
||||
{
|
||||
svc.ConstructUsing(() => new OpcUaService());
|
||||
svc.WhenStarted(s => s.Start());
|
||||
svc.WhenStopped(s => s.Stop());
|
||||
});
|
||||
|
||||
host.SetServiceName("LmxOpcUa");
|
||||
host.SetDisplayName("LMX OPC UA Server");
|
||||
host.SetDescription("OPC UA server exposing System Platform Galaxy tags via MXAccess.");
|
||||
host.RunAsLocalSystem();
|
||||
host.StartAutomatically();
|
||||
});
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Services.AddSerilog();
|
||||
builder.Services.AddWindowsService(o => o.ServiceName = "OtOpcUa");
|
||||
…
|
||||
builder.Services.AddHostedService<OpcUaServerService>();
|
||||
builder.Services.AddHostedService<HostStatusPublisher>();
|
||||
```
|
||||
|
||||
TopShelf provides these deployment modes from the same executable:
|
||||
`OpcUaServerService` is a `BackgroundService` (decision #30 — TopShelf from v1 was replaced by the generic-host `AddWindowsService` wrapper; no TopShelf dependency remains in any csproj). It owns:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `OtOpcUa.Host.exe` | Run as a console application (foreground) |
|
||||
| `OtOpcUa.Host.exe install` | Install as a Windows service |
|
||||
| `OtOpcUa.Host.exe uninstall` | Remove the Windows service |
|
||||
| `OtOpcUa.Host.exe start` | Start the installed service |
|
||||
| `OtOpcUa.Host.exe stop` | Stop the installed service |
|
||||
1. Config bootstrap — reads `Node:NodeId`, `Node:ClusterId`, `Node:ConfigDbConnectionString`, `Node:LocalCachePath` from `appsettings.json`.
|
||||
2. `NodeBootstrap` — pulls the latest published generation from the Config DB into the LiteDB local cache (`LiteDbConfigCache`) so the node starts even if the central DB is briefly unreachable.
|
||||
3. `DriverHost` — instantiates configured driver instances from the generation, wires each through `CapabilityInvoker` resilience pipelines.
|
||||
4. `OpcUaApplicationHost` — builds the OPC UA endpoint, applies `OpcUaServerOptions` + `LdapOptions`, registers `AuthorizationGate` at dispatch.
|
||||
5. `HostStatusPublisher` — a second hosted service that heartbeats `DriverHostStatus` rows so the Admin UI Fleet view sees the node.
|
||||
|
||||
The service is configured to run as `LocalSystem` and start automatically on boot.
|
||||
### Installation
|
||||
|
||||
## Working Directory
|
||||
Same executable, different modes driven by the .NET generic-host `AddWindowsService` wrapper:
|
||||
|
||||
Before configuring Serilog, `Program.Main()` sets the working directory to the executable's location:
|
||||
| Mode | Invocation |
|
||||
|---|---|
|
||||
| Console | `ZB.MOM.WW.OtOpcUa.Server.exe` |
|
||||
| Install as Windows service | `sc create OtOpcUa binPath="C:\Program Files\OtOpcUa\Server\ZB.MOM.WW.OtOpcUa.Server.exe" start=auto` |
|
||||
| Start | `sc start OtOpcUa` |
|
||||
| Stop | `sc stop OtOpcUa` |
|
||||
| Uninstall | `sc delete OtOpcUa` |
|
||||
|
||||
```csharp
|
||||
Environment.CurrentDirectory = AppDomain.CurrentDomain.BaseDirectory;
|
||||
```
|
||||
### Health endpoints
|
||||
|
||||
This is necessary because Windows services default their working directory to `System32`, which would cause relative log paths and `appsettings.json` to resolve incorrectly.
|
||||
The Server exposes `/healthz` + `/readyz` used by (a) the Admin `FleetStatusPoller` as input to Fleet status and (b) `PeerReachabilityTracker` in a peer Server process as the HTTP side of the peer-reachability probe.
|
||||
|
||||
## Startup Sequence
|
||||
## Admin process
|
||||
|
||||
`OpcUaService.Start()` executes the following steps in order. If any required step fails, the service logs the error and throws, preventing a partially initialized state.
|
||||
`src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs` is a stock `WebApplication`. Highlights:
|
||||
|
||||
1. **Load configuration** -- The production constructor reads `appsettings.json`, optional environment overlay, and environment variables, then binds each section to its typed configuration class.
|
||||
2. **Validate configuration** -- `ConfigurationValidator.ValidateAndLog()` logs all resolved values and checks required constraints (port range, non-empty names and connection strings). If validation fails, the service throws `InvalidOperationException`.
|
||||
3. **Register exception handler** -- Registers `AppDomain.CurrentDomain.UnhandledException` to log fatal unhandled exceptions with `IsTerminating` context.
|
||||
4. **Create performance metrics** -- Creates the `PerformanceMetrics` instance and a `CancellationTokenSource` for coordinating shutdown.
|
||||
5. **Create and connect MXAccess client** -- Starts the STA COM thread, creates the `MxAccessClient`, and attempts an initial connection. If the connection fails, the service logs a warning and continues -- the monitor loop will retry in the background.
|
||||
6. **Start MXAccess monitor** -- Starts the connectivity monitor loop that probes the runtime connection at the configured interval and handles auto-reconnect.
|
||||
7. **Test Galaxy repository connection** -- Calls `TestConnectionAsync()` on the Galaxy repository to verify the SQL Server database is reachable. If it fails, the service continues without initial address-space data.
|
||||
8. **Create OPC UA server host** -- Creates `OpcUaServerHost` with the effective MXAccess client (real, override, or null fallback), performance metrics, and an optional `IHistorianDataSource` obtained from `HistorianPluginLoader.TryLoad` when `Historian.Enabled=true` (returns `null` if the plugin is absent or fails to load).
|
||||
9. **Query Galaxy hierarchy** -- Fetches the object hierarchy and attribute definitions from the Galaxy repository database, recording object and attribute counts.
|
||||
10. **Start server and build address space** -- Starts the OPC UA server, retrieves the `LmxNodeManager`, and calls `BuildAddressSpace()` with the queried hierarchy and attributes. If the query or build fails, the server still starts with an empty address space.
|
||||
11. **Start change detection** -- Creates and starts `ChangeDetectionService`, which polls `galaxy.time_of_last_deploy` at the configured interval. When a change is detected, it triggers an address-space rebuild via the `OnGalaxyChanged` event.
|
||||
12. **Start status dashboard** -- Creates the `HealthCheckService` and `StatusReportService`, wires in all live components, and starts the `StatusWebServer` HTTP listener if the dashboard is enabled. If `StatusWebServer.Start()` returns `false` (port already bound, insufficient permissions, etc.), the service logs a warning, disposes the unstarted instance, sets `OpcUaService.DashboardStartFailed = true`, and continues in degraded mode. Matches the warning-continue policy applied to MxAccess connect, Galaxy DB connect, and initial address space build. Stability review 2026-04-13 Finding 2.
|
||||
13. **Log startup complete** -- Logs "LmxOpcUa service started successfully" at `Information` level.
|
||||
- Cookie auth (`CookieAuthenticationDefaults`, scheme name `OtOpcUa.Admin`) + Blazor Server (`AddInteractiveServerComponents`) + SignalR.
|
||||
- Authorization policies gated by `AdminRoles`: `ConfigViewer`, `ConfigEditor`, `FleetAdmin` (see `Services/AdminRoles.cs`). `CanEdit` policy requires `ConfigEditor` or `FleetAdmin`; `CanPublish` requires `FleetAdmin`.
|
||||
- `OtOpcUaConfigDbContext` registered against `ConnectionStrings:ConfigDb`.
|
||||
- Scoped services: `ClusterService`, `GenerationService`, `EquipmentService`, `UnsService`, `NamespaceService`, `DriverInstanceService`, `NodeAclService`, `PermissionProbeService`, `AclChangeNotifier`, `ReservationService`, `DraftValidationService`, `AuditLogService`, `HostStatusService`, `ClusterNodeService`, `EquipmentImportBatchService`, `ILdapGroupRoleMappingService`.
|
||||
- Singleton `RedundancyMetrics` (meter name `ZB.MOM.WW.OtOpcUa.Redundancy`) + `CertTrustService` (promotes rejected client certs in the Server's PKI store to trusted via the Admin Certificates page).
|
||||
- `LdapAuthService` bound to `Authentication:Ldap` — same LDAP flow as ScadaLink CentralUI for visual parity.
|
||||
- SignalR hubs mapped at `/hubs/fleet` and `/hubs/alerts`; `FleetStatusPoller` runs as a hosted service and pushes `RoleChanged`, host status, and alert events.
|
||||
- OpenTelemetry → Prometheus exporter at `/metrics` when `Metrics:Prometheus:Enabled=true` (default). Pull-based means no Collector required in the common K8s deploy.
|
||||
|
||||
## Shutdown Sequence
|
||||
### Installation
|
||||
|
||||
`OpcUaService.Stop()` tears down components in reverse dependency order:
|
||||
Deployed as an ASP.NET Core service; the generic-host `AddWindowsService` wrapper (or IIS reverse-proxy for multi-node fleets) provides install/uninstall. Listens on whatever `ASPNETCORE_URLS` specifies.
|
||||
|
||||
1. **Cancel operations** -- Signals the `CancellationTokenSource` to stop all background loops.
|
||||
2. **Stop change detection** -- Stops the Galaxy deploy polling loop.
|
||||
3. **Stop OPC UA server** -- Shuts down the OPC UA server host, disconnecting all client sessions.
|
||||
4. **Stop MXAccess monitor** -- Stops the connectivity monitor loop.
|
||||
5. **Disconnect MXAccess** -- Disconnects the MXAccess client and releases COM resources.
|
||||
6. **Dispose STA thread** -- Shuts down the dedicated STA COM thread and its message pump.
|
||||
7. **Stop dashboard** -- Disposes the `StatusWebServer` HTTP listener.
|
||||
8. **Dispose metrics** -- Releases the performance metrics collector.
|
||||
9. **Dispose change detection** -- Releases the change detection service.
|
||||
10. **Unregister exception handler** -- Removes the `AppDomain.UnhandledException` handler.
|
||||
## Galaxy.Host process
|
||||
|
||||
The entire shutdown is wrapped in a `try/catch` that logs warnings for errors during cleanup, ensuring the service exits even if a component fails to dispose cleanly.
|
||||
`src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Program.cs` is a .NET Framework 4.8 x86 console executable. Configuration comes from environment variables supplied by the supervisor (`Driver.Galaxy.Proxy.Supervisor`):
|
||||
|
||||
## Error Handling
|
||||
| Env var | Purpose |
|
||||
|---|---|
|
||||
| `OTOPCUA_GALAXY_PIPE` | Pipe name the host listens on (default `OtOpcUaGalaxy`). |
|
||||
| `OTOPCUA_ALLOWED_SID` | SID of the Server process's principal; anyone else is refused during the handshake. |
|
||||
| `OTOPCUA_GALAXY_SECRET` | Per-spawn shared secret the client must present in the Hello frame. |
|
||||
| `OTOPCUA_GALAXY_BACKEND` | `mxaccess` (default), `db` (ZB-only, no COM), `stub` (in-memory; for tests). |
|
||||
| `OTOPCUA_GALAXY_ZB_CONN` | SQL connection string to the ZB Galaxy repository. |
|
||||
| `OTOPCUA_HISTORIAN_*` | Optional Wonderware Historian SDK config if Historian is enabled for this node. |
|
||||
|
||||
### Unhandled exceptions
|
||||
The host spins up `StaPump` (the STA thread with message pump), creates the MXAccess `LMXProxyServer` COM object on that thread, and handles all COM calls there; the IPC layer marshals work items via `PostThreadMessage`.
|
||||
|
||||
`AppDomain.CurrentDomain.UnhandledException` is registered at startup and removed at shutdown. The handler logs the exception at `Fatal` level with the `IsTerminating` flag:
|
||||
### Pipe security
|
||||
|
||||
```csharp
|
||||
Log.Fatal(e.ExceptionObject as Exception,
|
||||
"Unhandled exception (IsTerminating={IsTerminating})", e.IsTerminating);
|
||||
```
|
||||
`PipeServer` builds a `PipeAcl` from the provided `SecurityIdentifier` + uses `NamedPipeServerStream` with `maxNumberOfServerInstances: 1`. The handshake requires a matching shared secret in the first Hello frame; callers whose SID doesn't match `OTOPCUA_ALLOWED_SID` are rejected before any frame is processed. **By design the pipe ACL denies BUILTIN\Administrators** — live smoke tests must therefore run from a non-elevated shell that matches the allowed principal. The installed dev host (`OtOpcUaGalaxyHost`) runs as `dohertj2` with the secret at `.local/galaxy-host-secret.txt`.
|
||||
|
||||
### Startup resilience
|
||||
### Installation
|
||||
|
||||
The startup sequence is designed to degrade gracefully rather than fail entirely:
|
||||
|
||||
- If MXAccess connection fails, the service continues with a `NullMxAccessClient` that returns bad-quality values for all reads.
|
||||
- If the Galaxy repository database is unreachable, the OPC UA server starts with an empty address space.
|
||||
- If the status dashboard port is in use, the dashboard logs a warning and does not start, but the OPC UA server continues.
|
||||
|
||||
### Fatal startup failure
|
||||
|
||||
If a critical step (configuration validation, OPC UA server start) throws, `Start()` catches the exception, logs it at `Fatal`, and re-throws to let TopShelf report the failure.
|
||||
|
||||
## Logging
|
||||
|
||||
The service uses Serilog with two sinks configured in `Program.Main()`:
|
||||
|
||||
```csharp
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File(
|
||||
path: "logs/lmxopcua-.log",
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 31)
|
||||
.CreateLogger();
|
||||
```
|
||||
|
||||
| Sink | Details |
|
||||
|------|---------|
|
||||
| Console | Writes to stdout, useful when running as a console application |
|
||||
| Rolling file | Writes to `logs/lmxopcua-{date}.log`, rolls daily, retains 31 days of history |
|
||||
|
||||
Log files are written relative to the executable directory (see Working Directory above). Each component creates its own contextual logger using `Log.ForContext<T>()` or `Log.ForContext(typeof(T))`.
|
||||
|
||||
`Log.CloseAndFlush()` is called in the `finally` block of `Program.Main()` to ensure all buffered log entries are written before process exit.
|
||||
|
||||
## Multi-Instance Deployment
|
||||
|
||||
The service supports running multiple instances for redundancy. Each instance requires:
|
||||
|
||||
- A unique Windows service name (e.g., `LmxOpcUa`, `LmxOpcUa2`)
|
||||
- A unique OPC UA port and dashboard port
|
||||
- A unique `OpcUa.ApplicationUri` and `OpcUa.ServerName`
|
||||
- A unique `MxAccess.ClientName`
|
||||
- Matching `Redundancy.ServerUris` arrays on all instances
|
||||
|
||||
Install additional instances using TopShelf's `-servicename` flag:
|
||||
NSSM-wrapped (the Non-Sucking Service Manager) because the executable itself is a plain console app, not a `ServiceBase` Windows service. The supervisor then adopts the child process over the pipe after install. Install/uninstall commands follow the NSSM pattern:
|
||||
|
||||
```bash
|
||||
cd C:\publish\lmxopcua\instance2
|
||||
ZB.MOM.WW.OtOpcUa.Host.exe install -servicename "LmxOpcUa2" -displayname "LMX OPC UA Server (Instance 2)"
|
||||
nssm install OtOpcUaGalaxyHost "C:\Program Files (x86)\OtOpcUa\Galaxy.Host\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.exe"
|
||||
nssm set OtOpcUaGalaxyHost ObjectName .\dohertj2 <password>
|
||||
nssm set OtOpcUaGalaxyHost AppEnvironmentExtra OTOPCUA_GALAXY_BACKEND=mxaccess OTOPCUA_GALAXY_SECRET=… OTOPCUA_ALLOWED_SID=…
|
||||
nssm start OtOpcUaGalaxyHost
|
||||
```
|
||||
|
||||
See [Redundancy Guide](Redundancy.md) for full deployment details.
|
||||
(Exact values for the environment block are generated by the Admin UI + committed alongside `.local/galaxy-host-secret.txt` on the dev box.)
|
||||
|
||||
## Required Runtime Assemblies
|
||||
|
||||
The build uses Costura.Fody to embed all NuGet dependencies into the single `ZB.MOM.WW.OtOpcUa.Host.exe`. The only native dependency that must sit alongside the executable in every deployment is the MXAccess COM toolkit:
|
||||
|
||||
| Assembly | Purpose |
|
||||
|----------|---------|
|
||||
| `ArchestrA.MxAccess.dll` | MXAccess COM interop — runtime data access to Galaxy tags |
|
||||
|
||||
The Wonderware Historian SDK is packaged as a **runtime-loaded plugin** so hosts that will not use historical data access do not need the SDK installed. The plugin lives in a `Historian/` subfolder next to `ZB.MOM.WW.OtOpcUa.Host.exe`:
|
||||
## Inter-process communication
|
||||
|
||||
```
|
||||
ZB.MOM.WW.OtOpcUa.Host.exe
|
||||
ArchestrA.MxAccess.dll
|
||||
Historian/
|
||||
ZB.MOM.WW.OtOpcUa.Historian.Aveva.dll
|
||||
aahClientManaged.dll
|
||||
aahClientCommon.dll
|
||||
aahClient.dll
|
||||
Historian.CBE.dll
|
||||
Historian.DPAPI.dll
|
||||
ArchestrA.CloudHistorian.Contract.dll
|
||||
┌──────────────────────────┐ LDAP bind (Authentication:Ldap) ┌──────────────────────────┐
|
||||
│ OtOpcUa Admin (x64) │ ─────────────────────────────────────────────▶│ LDAP / AD │
|
||||
│ Blazor Server + SignalR │ └──────────────────────────┘
|
||||
│ /metrics (Prometheus) │ FleetStatusPoller → ClusterNode poll
|
||||
│ │ ─────────────────────────────────────────────▶┌──────────────────────────┐
|
||||
│ │ Cluster/Generation/ACL writes │ Config DB (SQL Server) │
|
||||
└──────────────────────────┘ ─────────────────────────────────────────────▶│ OtOpcUaConfigDbContext │
|
||||
▲ └──────────────────────────┘
|
||||
│ SignalR ▲
|
||||
│ (role change, │ sp_GetCurrentGenerationForCluster
|
||||
│ host status, │ sp_PublishGeneration
|
||||
│ alerts) │
|
||||
┌──────────────────────────┐ │
|
||||
│ OtOpcUa Server (x64) │ ──────────────────────────────────────────────────────────┘
|
||||
│ OPC UA endpoint │
|
||||
│ Non-Galaxy drivers │ Named pipe (OtOpcUaGalaxy) ┌──────────────────────────┐
|
||||
│ Driver.Galaxy.Proxy │ ─────────────────────────────────────────────▶│ Galaxy.Host (x86 .NFx) │
|
||||
│ │ SID + shared-secret handshake │ STA + message pump │
|
||||
│ /healthz /readyz │ │ MXAccess COM │
|
||||
└──────────────────────────┘ │ Historian SDK (opt) │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
At startup, if `Historian.Enabled=true` in `appsettings.json`, `HistorianPluginLoader` probes `Historian/ZB.MOM.WW.OtOpcUa.Historian.Aveva.dll` via `Assembly.LoadFrom` and instantiates the plugin's entry point. An `AppDomain.AssemblyResolve` handler redirects the SDK assembly lookups (`aahClientManaged`, `aahClientCommon`, …) to the same subfolder so the CLR can resolve them when the plugin first JITs. If the plugin directory is absent or any SDK dependency fails to load, the loader logs a warning and the server continues to run with history support disabled — `LmxNodeManager` returns `BadHistoryOperationUnsupported` for every history call.
|
||||
## appsettings.json boundary
|
||||
|
||||
Deployment matrix:
|
||||
Each process reads its own `appsettings.json` for **bootstrap only** — connection strings, LDAP bind config, transport security profile, redundancy node id, logging. The authoritative configuration tree (drivers, UNS, tags, ACLs) lives in the Config DB and is edited through the Admin UI. See [`Configuration.md`](Configuration.md) for the split.
|
||||
|
||||
| Scenario | Host exe | `ArchestrA.MxAccess.dll` | `Historian/` subfolder |
|
||||
|----------|----------|--------------------------|------------------------|
|
||||
| `Historian.Enabled=false` | required | required | **omit** |
|
||||
| `Historian.Enabled=true` | required | required | required |
|
||||
## Development bootstrap
|
||||
|
||||
`ArchestrA.MxAccess.dll` and the historian SDK DLLs are not redistributable — they are provided by the AVEVA System Platform and Historian installations on the target machine. The copies in `lib/` are taken from `Program Files (x86)\ArchestrA\Framework\bin` on a machine with the platform installed.
|
||||
|
||||
## Platform Target
|
||||
|
||||
The service must be compiled and run as x86 (32-bit). The MXAccess COM toolkit DLLs in `Program Files (x86)\ArchestrA\Framework\bin` are 32-bit only. Running the service as x64 or AnyCPU (64-bit preferred) causes COM interop failures when creating the `LMXProxyServer` object on the STA thread.
|
||||
For the Windows install steps (SQL Server in Docker, .NET 10 SDK, .NET Framework 4.8 SDK, Docker Desktop WSL 2 backend, EF Core CLI, first-run migration), see [`docs/v2/dev-environment.md`](v2/dev-environment.md).
|
||||
|
||||
@@ -1,274 +1,16 @@
|
||||
# Status Dashboard
|
||||
# Status Dashboard — Superseded
|
||||
|
||||
## Overview
|
||||
This document has been superseded.
|
||||
|
||||
The service hosts an embedded HTTP status dashboard that surfaces real-time health, connection state, subscription counts, data change throughput, and Galaxy metadata. Operators access it through a browser to verify the bridge is functioning without needing an OPC UA client. The dashboard is enabled by default on port 8081 and can be disabled via configuration.
|
||||
The single-process, HTTP-listener "Status Dashboard" (`StatusWebServer` bound to port 8081) belonged to v1 LmxOpcUa, where one process owned the OPC UA endpoint, the MXAccess bridge, and the operator surface. In the multi-process OtOpcUa platform the operator surface has moved into the **OtOpcUa Admin** app — a Blazor Server UI that talks to the shared Config DB and to every deployed node over SignalR (`FleetStatusHub`, `AlertHub`). Prometheus scraping lives on the Admin app's `/metrics` endpoint via OpenTelemetry (`Metrics:Prometheus:Enabled`).
|
||||
|
||||
## HTTP Server
|
||||
Operator surfaces now covered by the Admin UI:
|
||||
|
||||
`StatusWebServer` wraps a `System.Net.HttpListener` bound to `http://+:{port}/`. It starts a background task that accepts requests in a loop and dispatches them by path. Only `GET` requests are accepted; all other methods return `405 Method Not Allowed`. Responses include `Cache-Control: no-cache` headers to prevent stale data in the browser.
|
||||
- Fleet health, per-node role/ServiceLevel, crash-loop detection (`Fleet.razor`, `Hosts.razor`, `FleetStatusPoller`)
|
||||
- Redundancy state + role transitions (`RedundancyMetrics`, `otopcua.redundancy.*`)
|
||||
- Cluster + node + credential management (`ClusterService`, `ClusterNodeService`)
|
||||
- Draft/publish generation editor, diff viewer, CSV import, UnsTab, IdentificationFields, RedundancyTab, AclsTab with Probe-this-permission
|
||||
- Certificate trust management (`CertTrustService` promotes rejected client certs to trusted)
|
||||
- Audit log viewer (`AuditLogService`)
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Path | Content-Type | Description |
|
||||
|------|-------------|-------------|
|
||||
| `/` | `text/html` | Operator dashboard with auto-refresh |
|
||||
| `/health` | `text/html` | Focused health page with service-level badge and component cards |
|
||||
| `/api/status` | `application/json` | Full status snapshot as JSON (`StatusData`) |
|
||||
| `/api/health` | `application/json` | Health endpoint (`HealthEndpointData`) -- returns `503` when status is `Unhealthy`, `200` otherwise |
|
||||
|
||||
Any other path returns `404 Not Found`.
|
||||
|
||||
## Health Check Logic
|
||||
|
||||
`HealthCheckService.CheckHealth` evaluates bridge health using the following rules applied in order. The first rule that matches wins; rules 2b, 2c, 2d, and 2e only fire when the corresponding integration is enabled and a non-null snapshot is passed:
|
||||
|
||||
1. **Rule 1 -- Unhealthy**: MXAccess connection state is not `Connected`. Returns a red banner with the current state.
|
||||
2. **Rule 2b -- Degraded**: `Historian.Enabled=true` but the plugin load outcome is not `Loaded`. Returns a yellow banner citing the plugin status (`NotFound`, `LoadFailed`) and the error message if one is available.
|
||||
3. **Rule 2 / 2c -- Degraded**: Any recorded operation has a low success rate. The sample threshold depends on the operation category:
|
||||
- Regular operations (`Read`, `Write`, `Subscribe`, `AlarmAcknowledge`): >100 invocations and <50% success rate.
|
||||
- Historian operations (`HistoryReadRaw`, `HistoryReadProcessed`, `HistoryReadAtTime`, `HistoryReadEvents`): >10 invocations and <50% success rate. The lower threshold surfaces a stuck historian quickly, since history reads are rare relative to live reads.
|
||||
4. **Rule 2d -- Degraded (latched)**: `AlarmTrackingEnabled=true` and any alarm acknowledge MXAccess write has failed since startup. Latched on purpose -- an ack write failure is a durable MXAccess write problem that should stay visible until the operator restarts.
|
||||
5. **Rule 2e -- Degraded**: `RuntimeStatus.StoppedCount > 0` -- at least one Galaxy runtime host (`$WinPlatform` / `$AppEngine`) is currently reported Stopped by the runtime probe manager. The rule names the stopped hosts in the message. Ordered after Rule 1 so an MxAccess transport outage stays `Unhealthy` via Rule 1 and this rule never double-messages; the probe manager also forces every entry to `Unknown` when the transport is disconnected, so the `StoppedCount` is always 0 in that case.
|
||||
6. **Rule 3 -- Healthy**: All checks pass. Returns a green banner with "All systems operational."
|
||||
|
||||
The `/api/health` endpoint returns `200` for both Healthy and Degraded states, and `503` only for Unhealthy. This allows load balancers or monitoring tools to distinguish between a service that is running but degraded and one that has lost its runtime connection.
|
||||
|
||||
## Status Data Model
|
||||
|
||||
`StatusReportService` aggregates data from all bridge components into a `StatusData` DTO, which is then rendered as HTML or serialized to JSON. The DTO contains the following sections:
|
||||
|
||||
### Connection
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `State` | `string` | Current MXAccess connection state (Connected, Disconnected, Connecting) |
|
||||
| `ReconnectCount` | `int` | Number of reconnect attempts since startup |
|
||||
| `ActiveSessions` | `int` | Number of active OPC UA client sessions |
|
||||
|
||||
### Health
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `Status` | `string` | Healthy, Degraded, or Unhealthy |
|
||||
| `Message` | `string` | Operator-facing explanation |
|
||||
| `Color` | `string` | CSS color token (green, yellow, red, gray) |
|
||||
|
||||
### Subscriptions
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `ActiveCount` | `int` | Number of active MXAccess tag subscriptions (includes bridge-owned runtime status probes — see `ProbeCount`) |
|
||||
| `ProbeCount` | `int` | Subset of `ActiveCount` attributable to bridge-owned runtime status probes (`<Host>.ScanState` per deployed `$WinPlatform` / `$AppEngine`). Rendered as a separate `Probes: N (bridge-owned runtime status)` line on the dashboard so operators can distinguish probe overhead from client-driven subscription load |
|
||||
|
||||
### Galaxy
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `GalaxyName` | `string` | Name of the Galaxy being bridged |
|
||||
| `DbConnected` | `bool` | Whether the Galaxy repository database is reachable |
|
||||
| `LastDeployTime` | `DateTime?` | Most recent deploy timestamp from the Galaxy |
|
||||
| `ObjectCount` | `int` | Number of Galaxy objects in the address space |
|
||||
| `AttributeCount` | `int` | Number of Galaxy attributes as OPC UA variables |
|
||||
| `LastRebuildTime` | `DateTime?` | UTC timestamp of the last completed address-space rebuild |
|
||||
|
||||
### Data change
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `EventsPerSecond` | `double` | Rate of MXAccess data change events per second |
|
||||
| `AvgBatchSize` | `double` | Average items processed per dispatch cycle |
|
||||
| `PendingItems` | `int` | Items waiting in the dispatch queue |
|
||||
| `TotalEvents` | `long` | Total MXAccess data change events since startup |
|
||||
|
||||
### Galaxy Runtime
|
||||
|
||||
Populated from the `GalaxyRuntimeProbeManager` that advises `<Host>.ScanState` on every deployed `$WinPlatform` and `$AppEngine`. See [MXAccess Bridge](MxAccessBridge.md#per-host-runtime-status-probes-hostscanstate) for the probe machinery, state machine, and the subtree quality invalidation that fires on transitions. Disabled when `MxAccess.RuntimeStatusProbesEnabled = false`; the panel is suppressed entirely from the HTML when `Total == 0`.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `Total` | `int` | Number of runtime hosts tracked (Platforms + AppEngines) |
|
||||
| `RunningCount` | `int` | Hosts whose last probe callback reported `ScanState = true` with Good quality |
|
||||
| `StoppedCount` | `int` | Hosts whose last probe callback reported `ScanState != true` or a failed item status, or whose initial probe timed out in Unknown state |
|
||||
| `UnknownCount` | `int` | Hosts still awaiting initial probe resolution, or rewritten to Unknown when the MxAccess transport is Disconnected |
|
||||
| `Hosts` | `List<GalaxyRuntimeStatus>` | Per-host detail rows, sorted alphabetically by `ObjectName` |
|
||||
|
||||
Each `GalaxyRuntimeStatus` entry:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `ObjectName` | `string` | Galaxy `tag_name` of the host (e.g., `DevPlatform`, `DevAppEngine`) |
|
||||
| `GobjectId` | `int` | Galaxy `gobject_id` of the host |
|
||||
| `Kind` | `string` | `$WinPlatform` or `$AppEngine` |
|
||||
| `State` | `enum` | `Unknown`, `Running`, or `Stopped` |
|
||||
| `LastStateCallbackTime` | `DateTime?` | UTC time of the most recent probe callback, whether good or bad |
|
||||
| `LastStateChangeTime` | `DateTime?` | UTC time of the most recent Running↔Stopped transition; backs the dashboard "Since" column |
|
||||
| `LastScanState` | `bool?` | Last `ScanState` value received; `null` before the first callback |
|
||||
| `LastError` | `string?` | Detail message from the most recent failure callback (e.g., `"ScanState = false (OffScan)"`); cleared on successful recovery |
|
||||
| `GoodUpdateCount` | `long` | Cumulative count of `ScanState = true` callbacks |
|
||||
| `FailureCount` | `long` | Cumulative count of `ScanState != true` callbacks or failed item statuses |
|
||||
|
||||
The HTML panel renders a per-host table with Name / Kind / State / Since / Last Error columns. Panel color reflects aggregate state: green when every host is `Running`, yellow when any host is `Unknown` with zero `Stopped`, red when any host is `Stopped`, gray when the MxAccess transport is disconnected (the Connection panel is the primary signal in that case and every row is force-rewritten to `Unknown`).
|
||||
|
||||
### Operations
|
||||
|
||||
A dictionary of `MetricsStatistics` keyed by operation name. Each entry contains:
|
||||
|
||||
- `TotalCount` -- total invocations
|
||||
- `SuccessRate` -- fraction of successful operations
|
||||
- `AverageMilliseconds`, `MinMilliseconds`, `MaxMilliseconds`, `Percentile95Milliseconds` -- latency distribution
|
||||
|
||||
The instrumented operation names are:
|
||||
|
||||
| Name | Source |
|
||||
|---|---|
|
||||
| `Read` | MXAccess live tag reads (`MxAccessClient.ReadWrite.cs`) |
|
||||
| `Write` | MXAccess live tag writes |
|
||||
| `Subscribe` | MXAccess subscription attach |
|
||||
| `HistoryReadRaw` | `LmxNodeManager.HistoryReadRawModified` -> historian plugin |
|
||||
| `HistoryReadProcessed` | `LmxNodeManager.HistoryReadProcessed` -> historian plugin (aggregates) |
|
||||
| `HistoryReadAtTime` | `LmxNodeManager.HistoryReadAtTime` -> historian plugin (interpolated) |
|
||||
| `HistoryReadEvents` | `LmxNodeManager.HistoryReadEvents` -> historian plugin (alarm/event history) |
|
||||
| `AlarmAcknowledge` | `LmxNodeManager.OnAlarmAcknowledge` -> MXAccess AckMsg write |
|
||||
|
||||
New operation names are auto-registered on first use, so the `Operations` dictionary only contains entries for features that have actually been exercised since startup.
|
||||
|
||||
### Historian
|
||||
|
||||
`HistorianStatusInfo` -- reflects the outcome of the runtime-loaded historian plugin and the runtime query-health counters. See [Historical Data Access](HistoricalDataAccess.md) for the plugin architecture and the [Runtime Health Counters](HistoricalDataAccess.md#runtime-health-counters) section for the data source instrumentation.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `Enabled` | `bool` | Whether `Historian.Enabled` is set in configuration |
|
||||
| `PluginStatus` | `string` | `Disabled`, `NotFound`, `LoadFailed`, or `Loaded` — load-time outcome from `HistorianPluginLoader.LastOutcome` |
|
||||
| `PluginError` | `string?` | Exception message from the last load attempt when `PluginStatus=LoadFailed`; otherwise `null` |
|
||||
| `PluginPath` | `string` | Absolute path the loader probed for the plugin assembly |
|
||||
| `ServerName` | `string` | Legacy single-node hostname from `Historian.ServerName`; ignored when `ServerNames` is non-empty |
|
||||
| `Port` | `int` | Configured historian TCP port |
|
||||
| `QueryTotal` | `long` | Total historian read queries attempted since startup (raw + aggregate + at-time + events) |
|
||||
| `QuerySuccesses` | `long` | Queries that completed without an exception |
|
||||
| `QueryFailures` | `long` | Queries that raised an exception — each failure also triggers the plugin's reconnect path |
|
||||
| `ConsecutiveFailures` | `int` | Failures since the last success. Resets to zero on any successful query. Drives the `Degraded` health rule at threshold 3 |
|
||||
| `LastSuccessTime` | `DateTime?` | UTC timestamp of the most recent successful query, or `null` when no query has succeeded since startup |
|
||||
| `LastFailureTime` | `DateTime?` | UTC timestamp of the most recent failure |
|
||||
| `LastQueryError` | `string?` | Exception message from the most recent failure. Prefixed with the read-path name (`raw:`, `aggregate:`, `at-time:`, `events:`) so operators can tell which SDK call failed |
|
||||
| `ProcessConnectionOpen` | `bool` | Whether the plugin currently holds an open SDK connection for the **process** silo (historical value queries — `ReadRaw`, `ReadAggregate`, `ReadAtTime`). See [Two SDK connection silos](HistoricalDataAccess.md#two-sdk-connection-silos) |
|
||||
| `EventConnectionOpen` | `bool` | Whether the plugin currently holds an open SDK connection for the **event** silo (alarm history queries — `ReadEvents`). Separate from the process connection because the SDK requires distinct query channels |
|
||||
| `ActiveProcessNode` | `string?` | Cluster node currently serving the process silo, or `null` when no process connection is open |
|
||||
| `ActiveEventNode` | `string?` | Cluster node currently serving the event silo, or `null` when no event connection is open |
|
||||
| `NodeCount` | `int` | Total configured historian cluster nodes. 1 for a legacy single-node deployment |
|
||||
| `HealthyNodeCount` | `int` | Nodes currently eligible for new connections (not in failure cooldown) |
|
||||
| `Nodes` | `List<HistorianClusterNodeState>` | Per-node cluster state in configuration order. Each entry carries `Name`, `IsHealthy`, `CooldownUntil`, `FailureCount`, `LastError`, `LastFailureTime` |
|
||||
|
||||
The operator dashboard renders a cluster table inside the Historian panel when `NodeCount > 1`. Legacy single-node deployments render a compact `Node: <hostname>` line and no table. Panel color reflects combined load-time + runtime health: green when everything is fine, yellow when any cluster node is in cooldown or 1-4 consecutive query failures are accumulated, red when the plugin is unloaded / all cluster nodes are failed / 5+ consecutive failures.
|
||||
|
||||
### Alarms
|
||||
|
||||
`AlarmStatusInfo` -- surfaces alarm-condition tracking health and dispatch counters.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `TrackingEnabled` | `bool` | Whether `OpcUa.AlarmTrackingEnabled` is set in configuration |
|
||||
| `ConditionCount` | `int` | Number of distinct alarm conditions currently tracked |
|
||||
| `ActiveAlarmCount` | `int` | Number of alarms currently in the `InAlarm=true` state |
|
||||
| `TransitionCount` | `long` | Total `InAlarm` transitions observed in the dispatch loop since startup |
|
||||
| `AckEventCount` | `long` | Total alarm acknowledgement transitions observed since startup |
|
||||
| `AckWriteFailures` | `long` | Total MXAccess AckMsg writes that have failed while processing alarm acknowledges. Any non-zero value latches the service into Degraded (see Rule 2d). |
|
||||
| `FilterEnabled` | `bool` | Whether `OpcUa.AlarmFilter.ObjectFilters` has any patterns configured |
|
||||
| `FilterPatternCount` | `int` | Number of compiled filter patterns (after comma-splitting and trimming) |
|
||||
| `FilterIncludedObjectCount` | `int` | Number of Galaxy objects included by the filter during the most recent address-space build. Zero when the filter is disabled. |
|
||||
|
||||
When the filter is active, the operator dashboard's Alarms panel renders an extra line `Filter: N pattern(s), M object(s) included` so operators can verify scope at a glance. See [Alarm Tracking](AlarmTracking.md#template-based-alarm-object-filter) for the matching rules and resolution algorithm.
|
||||
|
||||
### Redundancy
|
||||
|
||||
`RedundancyInfo` -- only populated when `Redundancy.Enabled=true` in configuration. Shows mode, role, computed service level, application URI, and the set of peer server URIs. See [Redundancy](Redundancy.md) for the full guide.
|
||||
|
||||
### Footer
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `Timestamp` | `DateTime` | UTC time when the snapshot was generated |
|
||||
| `Version` | `string` | Service assembly version |
|
||||
|
||||
## `/api/health` Payload
|
||||
|
||||
The health endpoint returns a `HealthEndpointData` document distinct from the full dashboard snapshot. It is designed for load balancers and external monitoring probes that only need an up/down signal plus component-level detail:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `Status` | `string` | `Healthy`, `Degraded`, or `Unhealthy` (drives the HTTP status code) |
|
||||
| `ServiceLevel` | `byte` | OPC UA-style 0-255 service level. 255 when healthy non-redundant; 0 when MXAccess is down; redundancy-adjusted otherwise |
|
||||
| `RedundancyEnabled` | `bool` | Whether redundancy is configured |
|
||||
| `RedundancyRole` | `string?` | `Primary` or `Secondary` when redundancy is enabled; `null` otherwise |
|
||||
| `RedundancyMode` | `string?` | `Warm` or `Hot` when redundancy is enabled; `null` otherwise |
|
||||
| `Components.MxAccess` | `string` | `Connected` or `Disconnected` |
|
||||
| `Components.Database` | `string` | `Connected` or `Disconnected` |
|
||||
| `Components.OpcUaServer` | `string` | `Running` or `Stopped` |
|
||||
| `Components.Historian` | `string` | `Disabled`, `NotFound`, `LoadFailed`, or `Loaded` -- matches `HistorianStatusInfo.PluginStatus` |
|
||||
| `Components.Alarms` | `string` | `Disabled` or `Enabled` -- mirrors `OpcUa.AlarmTrackingEnabled` |
|
||||
| `Uptime` | `string` | Formatted service uptime (e.g., `3d 5h 20m`) |
|
||||
| `Timestamp` | `DateTime` | UTC time the snapshot was generated |
|
||||
|
||||
Monitoring tools should:
|
||||
|
||||
- Alert on `Status=Unhealthy` (HTTP 503) for hard outages.
|
||||
- Alert on `Status=Degraded` (HTTP 200) for latched or cumulative failures -- a degraded status means the server is still operating but a subsystem needs attention (historian plugin missing, alarm ack writes failing, history read error rate too high, etc.).
|
||||
|
||||
## HTML Dashboards
|
||||
|
||||
### `/` -- Operator dashboard
|
||||
|
||||
Monospace, dark background, color-coded panels. Panels: Connection, Health, Redundancy (when enabled), Subscriptions, Data Change Dispatch, Galaxy Info, **Historian**, **Alarms**, Operations (table), Footer. Each panel border color reflects component state (green, yellow, red, or gray).
|
||||
|
||||
The page includes a `<meta http-equiv='refresh'>` tag set to the configured `RefreshIntervalSeconds` (default 10 seconds), so the browser polls automatically without JavaScript.
|
||||
|
||||
### `/health` -- Focused health view
|
||||
|
||||
Large status badge, computed `ServiceLevel` value, redundancy summary (when enabled), and a row of component cards: MXAccess, Galaxy Database, OPC UA Server, **Historian**, **Alarm Tracking**. Each card turns red when its component is in a failure state and grey when disabled. Best for wallboards and quick at-a-glance monitoring.
|
||||
|
||||
## Configuration
|
||||
|
||||
The dashboard is configured through the `Dashboard` section in `appsettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"Dashboard": {
|
||||
"Enabled": true,
|
||||
"Port": 8081,
|
||||
"RefreshIntervalSeconds": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Setting `Enabled` to `false` prevents the `StatusWebServer` from starting. The `StatusReportService` is still created so that other components can query health programmatically, but no HTTP listener is opened.
|
||||
|
||||
### Dashboard start failures are non-fatal
|
||||
|
||||
If the dashboard is enabled but the configured port is already bound (e.g., a previous instance did not clean up, another service is squatting on the port, or the user lacks URL-reservation rights), `StatusWebServer.Start()` logs the listener exception at Error level and returns `false`. `OpcUaService` then logs a Warning, disposes the unstarted instance, sets `DashboardStartFailed = true`, and continues in degraded mode — the OPC UA endpoint still starts. Operators can detect the failure by searching the service log for:
|
||||
|
||||
```
|
||||
[WRN] Status dashboard failed to bind on port {Port}; service continues without dashboard
|
||||
```
|
||||
|
||||
Stability review 2026-04-13 Finding 2.
|
||||
|
||||
## Component Wiring
|
||||
|
||||
`StatusReportService` is initialized after all other service components are created. `OpcUaService.Start()` calls `SetComponents()` to supply the live references, including the historian configuration so the dashboard can label the plugin target and evaluate Rule 2b:
|
||||
|
||||
```csharp
|
||||
StatusReportInstance.SetComponents(
|
||||
effectiveMxClient,
|
||||
Metrics,
|
||||
GalaxyStatsInstance,
|
||||
ServerHost,
|
||||
NodeManagerInstance,
|
||||
_config.Redundancy,
|
||||
_config.OpcUa.ApplicationUri,
|
||||
_config.Historian);
|
||||
```
|
||||
|
||||
This deferred wiring allows the report service to be constructed before the MXAccess client or node manager are fully initialized. If a component is `null`, the report service falls back to default values (e.g., `ConnectionState.Disconnected`, zero counts, `HistorianPluginStatus.Disabled`).
|
||||
|
||||
The historian plugin status is sourced from `HistorianPluginLoader.LastOutcome`, which is updated on every load attempt. `OpcUaService` explicitly calls `HistorianPluginLoader.MarkDisabled()` when `Historian.Enabled=false` so the dashboard can distinguish "feature off" from "load failed" without ambiguity.
|
||||
See [`docs/v2/admin-ui.md`](v2/admin-ui.md) for the current operator surface and [`docs/ServiceHosting.md`](ServiceHosting.md) for the three-process layout.
|
||||
|
||||
@@ -1,135 +1,60 @@
|
||||
# Subscriptions
|
||||
|
||||
`LmxNodeManager` bridges OPC UA monitored items to MXAccess runtime subscriptions using reference counting and a decoupled dispatch architecture. This design ensures that MXAccess COM callbacks (which run on the STA thread) never contend with the OPC UA framework lock.
|
||||
Driver-side data-change subscriptions live behind `ISubscribable` (`src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ISubscribable.cs`). The interface is deliberately mechanism-agnostic: it covers native subscriptions (Galaxy MXAccess advisory, OPC UA monitored items on an upstream server, TwinCAT ADS notifications) and driver-internal polled subscriptions (Modbus, AB CIP, S7, FOCAS). Core sees the same event shape regardless — drivers fire `OnDataChange` and Core dispatches to the matching OPC UA monitored items.
|
||||
|
||||
## Ref-Counted MXAccess Subscriptions
|
||||
|
||||
Multiple OPC UA clients can subscribe to the same Galaxy tag simultaneously. Rather than opening duplicate MXAccess subscriptions, `LmxNodeManager` maintains a reference count per tag in `_subscriptionRefCounts`.
|
||||
|
||||
### SubscribeTag
|
||||
|
||||
`SubscribeTag` increments the reference count for a tag reference. On the first subscription (count goes from 0 to 1), it calls `_mxAccessClient.SubscribeAsync` to open the MXAccess runtime subscription:
|
||||
## ISubscribable surface
|
||||
|
||||
```csharp
|
||||
internal void SubscribeTag(string fullTagReference)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_subscriptionRefCounts.TryGetValue(fullTagReference, out var count))
|
||||
_subscriptionRefCounts[fullTagReference] = count + 1;
|
||||
else
|
||||
{
|
||||
_subscriptionRefCounts[fullTagReference] = 1;
|
||||
_ = _mxAccessClient.SubscribeAsync(fullTagReference, (_, _) => { });
|
||||
}
|
||||
}
|
||||
}
|
||||
Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences,
|
||||
TimeSpan publishingInterval,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken);
|
||||
|
||||
event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
```
|
||||
|
||||
### UnsubscribeTag
|
||||
A single `SubscribeAsync` call may batch many attributes and returns an opaque handle the caller passes back to `UnsubscribeAsync`. The driver may emit an immediate `OnDataChange` for each subscribed reference (the OPC UA initial-data convention) and then a push per change.
|
||||
|
||||
`UnsubscribeTag` decrements the reference count. When the count reaches zero, the MXAccess subscription is closed via `UnsubscribeAsync` and the tag is removed from the dictionary:
|
||||
Every subscribe / unsubscribe call goes through `CapabilityInvoker.ExecuteAsync(DriverCapability.Subscribe, host, …)` so the per-host pipeline applies.
|
||||
|
||||
```csharp
|
||||
if (count <= 1)
|
||||
{
|
||||
_subscriptionRefCounts.Remove(fullTagReference);
|
||||
_ = _mxAccessClient.UnsubscribeAsync(fullTagReference);
|
||||
}
|
||||
else
|
||||
_subscriptionRefCounts[fullTagReference] = count - 1;
|
||||
```
|
||||
## Reference counting at Core
|
||||
|
||||
Both methods use `lock (_lock)` (a private object, distinct from the OPC UA framework `Lock`) to serialize ref-count updates without blocking node value dispatches.
|
||||
Multiple OPC UA clients can monitor the same variable simultaneously. Rather than open duplicate driver subscriptions, Core maintains a ref-count per `(driver, fullReference)` pair: the first OPC UA monitored-item for a reference triggers `ISubscribable.SubscribeAsync` with that single reference; each additional monitored-item just increments the count; decrement-to-zero triggers `UnsubscribeAsync`. Transferred subscriptions (client reconnect → resume session) replay against the same ref-count map so active driver subscriptions are preserved across session migration.
|
||||
|
||||
## OnMonitoredItemCreated
|
||||
## Threading
|
||||
|
||||
The OPC UA framework calls `OnMonitoredItemCreated` when a client creates a monitored item. The override resolves the node handle to a tag reference and calls `SubscribeTag`, which opens the MXAccess subscription early so runtime values start arriving before the first publish cycle:
|
||||
The STA thread story is now driver-specific, not a server-wide concern:
|
||||
|
||||
```csharp
|
||||
protected override void OnMonitoredItemCreated(ServerSystemContext context,
|
||||
NodeHandle handle, MonitoredItem monitoredItem)
|
||||
{
|
||||
base.OnMonitoredItemCreated(context, handle, monitoredItem);
|
||||
var nodeIdStr = handle?.NodeId?.Identifier as string;
|
||||
if (nodeIdStr != null && _nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
|
||||
SubscribeTag(tagRef);
|
||||
}
|
||||
```
|
||||
- **Galaxy** runs its MXAccess COM objects on a dedicated STA thread with a Win32 message pump (`src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/StaPump.cs`) inside the standalone `Driver.Galaxy.Host` Windows service. The Proxy driver (`Driver.Galaxy.Proxy`) connects to the Host via named pipe and re-exposes the data on a free-threaded surface to Core. Core never touches COM.
|
||||
- **Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS** are free-threaded — they run their polling loops on ordinary `Task`s. Their `OnDataChange` fires on thread-pool threads.
|
||||
- **OPC UA Client** delegates to the OPC Foundation stack's subscription loop.
|
||||
|
||||
`OnDeleteMonitoredItemsComplete` performs the inverse, calling `UnsubscribeTag` for each deleted monitored item.
|
||||
The common contract: drivers are responsible for marshalling from whatever native thread the backend uses onto thread-pool threads before raising `OnDataChange`. Core's dispatch path acquires the OPC UA framework `Lock` and calls `ClearChangeMasks` on the corresponding `BaseDataVariableState` to notify subscribed clients.
|
||||
|
||||
## Data Change Dispatch Queue
|
||||
## Dispatch
|
||||
|
||||
MXAccess delivers data change callbacks on the STA thread via the `OnTagValueChanged` event. These callbacks must not acquire the OPC UA framework `Lock` directly because the lock is also held during `Read`/`Write` operations that call into MXAccess (creating a potential deadlock with the STA thread). The solution is a `ConcurrentDictionary<string, Vtq>` named `_pendingDataChanges` that decouples the two threads.
|
||||
Core's subscription dispatch path:
|
||||
|
||||
### Callback handler
|
||||
1. `ISubscribable.OnDataChange` fires on a thread-pool thread with a `DataChangeEventArgs(subscriptionHandle, fullReference, DataValueSnapshot)`.
|
||||
2. Core looks up the variable by `fullReference` in the driver's `DriverNodeManager` variable map.
|
||||
3. Under the OPC UA framework `Lock`, the variable's `Value` / `StatusCode` / `Timestamp` are updated and `ClearChangeMasks(SystemContext, false)` is called.
|
||||
4. The OPC Foundation stack then enqueues data-change notifications for every monitored-item attached to that variable, honoring each subscription's sampling + filter configuration.
|
||||
|
||||
`OnMxAccessDataChange` runs on the STA thread. It stores the latest value in the concurrent dictionary (coalescing rapid updates for the same tag) and signals the dispatch thread:
|
||||
Batch coalescing — coalescing multiple pushes for the same reference between publish cycles — is done driver-side when the backend natively supports it (Galaxy keeps the v1 coalescing dictionary); otherwise the SDK's own data-change filter suppresses no-change notifications.
|
||||
|
||||
```csharp
|
||||
private void OnMxAccessDataChange(string address, Vtq vtq)
|
||||
{
|
||||
Interlocked.Increment(ref _totalMxChangeEvents);
|
||||
_pendingDataChanges[address] = vtq;
|
||||
_dataChangeSignal.Set();
|
||||
}
|
||||
```
|
||||
## Initial values
|
||||
|
||||
### Dispatch thread architecture
|
||||
A freshly-built variable carries `StatusCode = BadWaitingForInitialData` until the driver delivers the first value. Drivers whose backends supply an initial read (Galaxy `AdviseSupervisory`, TwinCAT `AddDeviceNotification`) fire `OnDataChange` immediately after `SubscribeAsync` returns. Polled drivers fire the first push when their first poll cycle completes.
|
||||
|
||||
A dedicated background thread (`OpcUaDataChangeDispatch`) runs `DispatchLoop`, which waits on an `AutoResetEvent` with a 100ms timeout. The decoupled design exists for two reasons:
|
||||
## Transferred subscription restoration
|
||||
|
||||
1. **Deadlock avoidance** -- The STA thread must not acquire the OPC UA `Lock`. The dispatch thread is a normal background thread that can safely acquire `Lock`.
|
||||
2. **Batch coalescing** -- Multiple MXAccess callbacks for the same tag between dispatch cycles are collapsed to the latest value via dictionary key overwrite. Under high load, this reduces the number of `ClearChangeMasks` calls.
|
||||
When an OPC UA session is resumed (client reconnect with `TransferSubscriptions`), Core walks the transferred monitored-items and ensures every referenced `(driver, fullReference)` has a live driver subscription. References already active (in-process migration) skip re-subscribing; references that lost their driver-side handle during the session gap are re-subscribed via `SubscribeAsync`.
|
||||
|
||||
The dispatch loop processes changes in two phases:
|
||||
## Key source files
|
||||
|
||||
**Phase 1 (outside Lock):** Drain keys from `_pendingDataChanges`, convert each `Vtq` to a `DataValue` via `CreatePublishedDataValue`, and collect alarm transition events. MXAccess reads for alarm Priority and DescAttrName values also happen in this phase, since they call back into the STA thread.
|
||||
|
||||
**Phase 2 (inside Lock):** Apply all prepared updates to variable nodes and call `ClearChangeMasks` on each to trigger OPC UA data change notifications. Alarm events are reported in this same lock scope.
|
||||
|
||||
```csharp
|
||||
lock (Lock)
|
||||
{
|
||||
foreach (var (variable, dataValue) in updates)
|
||||
{
|
||||
variable.Value = dataValue.Value;
|
||||
variable.StatusCode = dataValue.StatusCode;
|
||||
variable.Timestamp = dataValue.SourceTimestamp;
|
||||
variable.ClearChangeMasks(SystemContext, false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ClearChangeMasks
|
||||
|
||||
`ClearChangeMasks(SystemContext, false)` is the mechanism that notifies the OPC UA framework a node's value has changed. The framework uses change masks internally to track which nodes have pending notifications for active monitored items. Calling this method causes the server to enqueue data change notifications for all monitoring clients of that node. The `false` parameter indicates that child nodes should not be recursively cleared.
|
||||
|
||||
## Transferred Subscription Restoration
|
||||
|
||||
When OPC UA sessions are transferred (e.g., client reconnects and resumes a previous session), the framework calls `OnMonitoredItemsTransferred`. The override collects the tag references for all transferred items and calls `RestoreTransferredSubscriptions`.
|
||||
|
||||
`RestoreTransferredSubscriptions` groups the tag references by count and, for each tag that does not already have an active ref-count entry, opens a new MXAccess subscription and sets the initial reference count:
|
||||
|
||||
```csharp
|
||||
internal void RestoreTransferredSubscriptions(IEnumerable<string> fullTagReferences)
|
||||
{
|
||||
var transferredCounts = fullTagReferences
|
||||
.GroupBy(tagRef => tagRef, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var kvp in transferredCounts)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_subscriptionRefCounts.ContainsKey(kvp.Key))
|
||||
continue;
|
||||
_subscriptionRefCounts[kvp.Key] = kvp.Value;
|
||||
}
|
||||
_ = _mxAccessClient.SubscribeAsync(kvp.Key, (_, _) => { });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Tags that already have in-memory bookkeeping are skipped to avoid double-counting when the transfer happens within the same server process (normal in-process session migration).
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ISubscribable.cs` — capability contract
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — pipeline wrapping
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/StaPump.cs` — Galaxy STA thread + message pump
|
||||
- Per-driver subscribe implementations in each `Driver.*` project
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
# Galaxy Repository
|
||||
# Galaxy Repository — Tag Discovery for the Galaxy Driver
|
||||
|
||||
`GalaxyRepositoryService` reads the Galaxy object hierarchy and attribute metadata from the System Platform Galaxy Repository SQL Server database. This data drives the construction of the OPC UA address space.
|
||||
`GalaxyRepositoryService` reads the Galaxy object hierarchy and attribute metadata from the System Platform Galaxy Repository SQL Server database. It is the Galaxy driver's implementation of **`ITagDiscovery.DiscoverAsync`** — every driver has its own discovery source, and the Galaxy driver's is a direct SQL query against the Galaxy Repository (the `ZB` database). Other drivers use completely different mechanisms:
|
||||
|
||||
| Driver | `ITagDiscovery` source |
|
||||
|--------|------------------------|
|
||||
| Galaxy | ZB SQL hierarchy + attribute queries (this doc) |
|
||||
| AB CIP | `@tags` walker against the PLC controller |
|
||||
| AB Legacy | Data-table scan via PCCC `LogicalRead` on the PLC |
|
||||
| TwinCAT | Beckhoff `SymbolLoaderFactory` — uploads the full symbol tree from the ADS runtime |
|
||||
| S7 | Config-DB enumeration (no native symbol upload for S7comm) |
|
||||
| Modbus | Config-DB enumeration (flat register map, user-authored) |
|
||||
| FOCAS | CNC queries (`cnc_rdaxisname`, `cnc_rdmacroinfo`, …) + optional Config-DB overlays |
|
||||
| OPC UA Client | `Session.Browse` against the remote server |
|
||||
|
||||
`GalaxyRepositoryService` lives in `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/GalaxyRepository/` — Host-side, .NET Framework 4.8 x86, same process that owns the MXAccess COM objects. The Proxy forwards discovery over IPC the same way it forwards reads and writes.
|
||||
|
||||
## Connection Configuration
|
||||
|
||||
@@ -19,7 +32,7 @@ The connection uses Windows Authentication because the Galaxy Repository databas
|
||||
|
||||
## SQL Queries
|
||||
|
||||
All queries are embedded as `const string` fields in `GalaxyRepositoryService`. No dynamic SQL is used.
|
||||
All queries are embedded as `const string` fields in `GalaxyRepositoryService`. No dynamic SQL is used. Project convention `GR-006` requires `const string` SQL queries; any new query must be added as a named constant rather than built at runtime.
|
||||
|
||||
### Hierarchy query
|
||||
|
||||
@@ -31,9 +44,9 @@ Returns deployed Galaxy objects with their parent relationships, browse names, a
|
||||
- Marks objects with `category_id = 13` as areas
|
||||
- Filters to `is_template = 0` (instances only, not templates)
|
||||
- Filters to `deployed_package_id <> 0` (deployed objects only)
|
||||
- Returns a `template_chain` column built by a recursive CTE that walks `gobject.derived_from_gobject_id` from each instance through its immediate template and ancestor templates (depth guard `< 10`). Template names are ordered by depth and joined with `|` via `STUFF(... FOR XML PATH(''))`. Example: `TestMachine_001` returns `$TestMachine|$gMachine|$gUserDefined|$UserDefined`. The C# repository reader splits the column on `|`, trims, and populates `GalaxyObjectInfo.TemplateChain`, which is consumed by `AlarmObjectFilter` for template-based alarm filtering. See [Alarm Tracking](AlarmTracking.md#template-based-alarm-object-filter).
|
||||
- Returns `template_definition.category_id` as a `category_id` column, populated into `GalaxyObjectInfo.CategoryId`. The runtime status probe manager filters this down to `CategoryId == 1` (`$WinPlatform`) and `CategoryId == 3` (`$AppEngine`) to decide which objects get a `<Host>.ScanState` probe advised. Also used by `LmxNodeManager.BuildHostedVariablesMap` to identify Platform/Engine ancestors during the hosted-variables walk.
|
||||
- Returns `gobject.hosted_by_gobject_id` as a `hosted_by_gobject_id` column, populated into `GalaxyObjectInfo.HostedByGobjectId`. This is the **runtime host** of the object (e.g., which `$AppEngine` actually runs it), **not** the browse-containment parent (`contained_by_gobject_id`). The two are often different — an object can live in one Area in the browse tree but be hosted by an Engine on a different Platform for runtime execution. The node manager walks this chain during `BuildHostedVariablesMap` to find the nearest `$WinPlatform` or `$AppEngine` ancestor so subtree quality invalidation on a Stopped host reaches exactly the variables that were actually executing there. Note: the Galaxy schema column is named `hosted_by_gobject_id` (not `host_gobject_id` as some documentation sources guess). See [MXAccess Bridge — Per-Host Runtime Status Probes](MxAccessBridge.md#per-host-runtime-status-probes-hostscanstate).
|
||||
- Returns a `template_chain` column built by a recursive CTE that walks `gobject.derived_from_gobject_id` from each instance through its immediate template and ancestor templates (depth guard `< 10`). Template names are ordered by depth and joined with `|` via `STUFF(... FOR XML PATH(''))`. Example: `TestMachine_001` returns `$TestMachine|$gMachine|$gUserDefined|$UserDefined`. The C# repository reader splits the column on `|`, trims, and populates `GalaxyObjectInfo.TemplateChain`, which is consumed by `AlarmObjectFilter` for template-based alarm filtering. See [Alarm Tracking](../AlarmTracking.md#template-based-alarm-object-filter).
|
||||
- Returns `template_definition.category_id` as a `category_id` column, populated into `GalaxyObjectInfo.CategoryId`. The runtime status probe manager filters this down to `CategoryId == 1` (`$WinPlatform`) and `CategoryId == 3` (`$AppEngine`) to decide which objects get a `<Host>.ScanState` probe advised. Also used during the hosted-variables walk to identify Platform/Engine ancestors.
|
||||
- Returns `gobject.hosted_by_gobject_id` as a `hosted_by_gobject_id` column, populated into `GalaxyObjectInfo.HostedByGobjectId`. This is the **runtime host** of the object (e.g., which `$AppEngine` actually runs it), **not** the browse-containment parent (`contained_by_gobject_id`). The two are often different — an object can live in one Area in the browse tree but be hosted by an Engine on a different Platform for runtime execution. The driver walks this chain during `BuildHostedVariablesMap` to find the nearest `$WinPlatform` or `$AppEngine` ancestor so subtree quality invalidation on a Stopped host reaches exactly the variables that were actually executing there. Note: the Galaxy schema column is named `hosted_by_gobject_id` (not `host_gobject_id` as some documentation sources guess). See [Galaxy driver — Per-Host Runtime Status Probes](Galaxy.md#per-host-runtime-status-probes-hostscanstate).
|
||||
|
||||
### Attributes query (standard)
|
||||
|
||||
@@ -53,8 +66,8 @@ Returns user-defined dynamic attributes for deployed objects:
|
||||
|
||||
When `ExtendedAttributes = true`, a more comprehensive query runs that unions two sources:
|
||||
|
||||
1. **Primitive attributes** -- Joins through `primitive_instance` and `attribute_definition` to include system-level attributes from primitive components. Each attribute carries its `primitive_name` so the address space can group them under their parent variable.
|
||||
2. **Dynamic attributes** -- The same CTE-based query as the standard path, with an empty `primitive_name`.
|
||||
1. **Primitive attributes** — Joins through `primitive_instance` and `attribute_definition` to include system-level attributes from primitive components. Each attribute carries its `primitive_name` so the address space can group them under their parent variable.
|
||||
2. **Dynamic attributes** — The same CTE-based query as the standard path, with an empty `primitive_name`.
|
||||
|
||||
The `full_tag_reference` for primitive attributes follows the pattern `tag_name.primitive_name.attribute_name` (e.g., `TestMachine_001.AlarmAttr.InAlarm`).
|
||||
|
||||
@@ -66,10 +79,10 @@ A single-column query: `SELECT time_of_last_deploy FROM galaxy`. The `galaxy` ta
|
||||
|
||||
The Galaxy maintains two package references for each object:
|
||||
|
||||
- `checked_in_package_id` -- The latest saved version, which may include undeployed configuration changes
|
||||
- `deployed_package_id` -- The version currently running on the target platform
|
||||
- `checked_in_package_id` — the latest saved version, which may include undeployed configuration changes
|
||||
- `deployed_package_id` — the version currently running on the target platform
|
||||
|
||||
The queries filter on `deployed_package_id <> 0` because the OPC UA server must mirror what is actually running in the Galaxy runtime. Using `checked_in_package_id` would expose attributes and objects that exist in the IDE but have not been deployed, causing mismatches between the OPC UA address space and the MXAccess runtime.
|
||||
The queries filter on `deployed_package_id <> 0` because the OPC UA address space must mirror what is actually running in the Galaxy runtime. Using `checked_in_package_id` would expose attributes and objects that exist in the IDE but have not been deployed, causing mismatches between the OPC UA address space and the MXAccess runtime.
|
||||
|
||||
## Platform Scope Filter
|
||||
|
||||
@@ -77,21 +90,16 @@ When `Scope` is set to `LocalPlatform`, the repository applies a post-query C# f
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Platform lookup** -- A separate `const string` SQL query (`PlatformLookupSql`) reads `platform_gobject_id` and `node_name` from the `platform` table for all deployed platforms. This runs once per hierarchy load.
|
||||
|
||||
2. **Platform matching** -- The configured `PlatformName` (or `Environment.MachineName` when null) is matched case-insensitively against the `node_name` column. If no match is found, a warning is logged listing the available platforms, and the address space is empty.
|
||||
|
||||
3. **Host chain collection** -- The filter collects the matching platform's `gobject_id`, then iterates the hierarchy to find all `$AppEngine` (category 3) objects whose `HostedByGobjectId` equals the platform. This produces the full set of host gobject_ids under the local platform.
|
||||
|
||||
4. **Object inclusion** -- All non-area objects whose `HostedByGobjectId` is in the host set are included, along with the hosts themselves.
|
||||
|
||||
5. **Area retention** -- `ParentGobjectId` chains are walked upward from included objects to pull in ancestor areas, keeping the browse tree connected. Areas that contain no local descendants are excluded.
|
||||
|
||||
6. **Attribute filtering** -- The set of included `gobject_id` values is cached after `GetHierarchyAsync` and reused by `GetAttributesAsync` to filter attributes to the same scope.
|
||||
1. **Platform lookup** — A separate `const string` SQL query (`PlatformLookupSql`) reads `platform_gobject_id` and `node_name` from the `platform` table for all deployed platforms. This runs once per hierarchy load.
|
||||
2. **Platform matching** — The configured `PlatformName` (or `Environment.MachineName` when null) is matched case-insensitively against the `node_name` column. If no match is found, a warning is logged listing the available platforms and the address space is empty.
|
||||
3. **Host chain collection** — The filter collects the matching platform's `gobject_id`, then iterates the hierarchy to find all `$AppEngine` (category 3) objects whose `HostedByGobjectId` equals the platform. This produces the full set of host gobject_ids under the local platform.
|
||||
4. **Object inclusion** — All non-area objects whose `HostedByGobjectId` is in the host set are included, along with the hosts themselves.
|
||||
5. **Area retention** — `ParentGobjectId` chains are walked upward from included objects to pull in ancestor areas, keeping the browse tree connected. Areas that contain no local descendants are excluded.
|
||||
6. **Attribute filtering** — The set of included `gobject_id` values is cached after `GetHierarchyAsync` and reused by `GetAttributesAsync` to filter attributes to the same scope.
|
||||
|
||||
### Design rationale
|
||||
|
||||
The filter is applied in C# rather than SQL because the project convention `GR-006` requires `const string` SQL queries with no dynamic SQL. The hierarchy query already returns `HostedByGobjectId` and `CategoryId` on every row, so all information needed for filtering is already in memory after the query runs. The only new SQL is the lightweight platform lookup query.
|
||||
The filter is applied in C# rather than SQL because project convention `GR-006` requires `const string` SQL queries with no dynamic SQL. The hierarchy query already returns `HostedByGobjectId` and `CategoryId` on every row, so all information needed for filtering is already in memory after the query runs. The only new SQL is the lightweight platform lookup query.
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -102,7 +110,7 @@ The filter is applied in C# rather than SQL because the project convention `GR-0
|
||||
}
|
||||
```
|
||||
|
||||
- Set `Scope` to `"LocalPlatform"` to enable filtering. Default is `"Galaxy"` (load everything, backward compatible).
|
||||
- Set `Scope` to `"LocalPlatform"` to enable filtering. Default is `"Galaxy"` (load everything).
|
||||
- Set `PlatformName` to an explicit hostname to target a specific platform, or leave null to use the local machine name.
|
||||
|
||||
### Startup log
|
||||
@@ -119,25 +127,26 @@ GetAttributesAsync returned 4206 attributes (extended=true)
|
||||
Scope filter retained 2100 of 4206 attributes
|
||||
```
|
||||
|
||||
## Change Detection Polling
|
||||
## Change Detection Polling and IRediscoverable
|
||||
|
||||
`ChangeDetectionService` runs a background polling loop that calls `GetLastDeployTimeAsync` at the configured interval. It compares the returned timestamp against the last known value:
|
||||
`ChangeDetectionService` runs a background polling loop in the Host process that calls `GetLastDeployTimeAsync` at the configured interval. It compares the returned timestamp against the last known value:
|
||||
|
||||
- On the first poll (no previous state), the timestamp is recorded and `OnGalaxyChanged` fires unconditionally
|
||||
- On subsequent polls, `OnGalaxyChanged` fires only when `time_of_last_deploy` differs from the cached value
|
||||
|
||||
When the event fires, the host service queries fresh hierarchy and attribute data from the repository and calls `LmxNodeManager.RebuildAddressSpace` (which delegates to incremental `SyncAddressSpace`).
|
||||
When the event fires, the Host re-runs the hierarchy and attribute queries and pushes the result back to the Server via an IPC `RediscoveryNeeded` message. That surfaces on `GalaxyProxyDriver` as the **`IRediscoverable.OnRediscoveryNeeded`** event; the Server's `DriverNodeManager` consumes it and calls `SyncAddressSpace` to compute the diff against the live address space.
|
||||
|
||||
The polling approach is used because the Galaxy Repository database does not provide change notifications. The `galaxy.time_of_last_deploy` column updates only on completed deployments, so the polling interval controls how quickly the OPC UA address space reflects Galaxy changes.
|
||||
|
||||
## TestConnection
|
||||
|
||||
`TestConnectionAsync` runs `SELECT 1` against the configured database. This is used at service startup to verify connectivity before attempting the full hierarchy query.
|
||||
`TestConnectionAsync` runs `SELECT 1` against the configured database. This is used at Host startup to verify connectivity before attempting the full hierarchy query.
|
||||
|
||||
## Key source files
|
||||
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs` -- SQL queries and data access
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/PlatformScopeFilter.cs` -- Platform-based hierarchy and attribute filtering
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/GalaxyRepository/ChangeDetectionService.cs` -- Deploy timestamp polling loop
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs` -- Connection, polling, and scope settings
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/Domain/PlatformInfo.cs` -- Platform-to-hostname DTO
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/GalaxyRepository/GalaxyRepositoryService.cs` — SQL queries and data access
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/GalaxyRepository/PlatformScopeFilter.cs` — Platform-based hierarchy and attribute filtering
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/GalaxyRepository/ChangeDetectionService.cs` — Deploy timestamp polling loop
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Configuration/GalaxyRepositoryConfiguration.cs` — Connection, polling, and scope settings
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Domain/PlatformInfo.cs` — Platform-to-hostname DTO
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/DiscoveryResponse.cs` — IPC DTO the Host uses to return hierarchy + attribute results across the pipe
|
||||
211
docs/drivers/Galaxy.md
Normal file
211
docs/drivers/Galaxy.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# Galaxy Driver
|
||||
|
||||
The Galaxy driver bridges OtOpcUa to AVEVA System Platform (Wonderware) Galaxies through the `ArchestrA.MxAccess` COM API plus the Galaxy Repository SQL database. It is one driver of seven in the OtOpcUa platform (see [drivers/README.md](README.md) for the full list); all other drivers run in-process in the main Server (.NET 10 x64). Galaxy is the exception — it runs as its own Windows service and talks to the Server over a local named pipe.
|
||||
|
||||
For the decision record on why Galaxy is out-of-process and how the refactor was staged, see [docs/v2/plan.md §4 Galaxy/MXAccess as Out-of-Process Driver](../v2/plan.md). For the full driver spec (addressing, data-type map, config shape), see [docs/v2/driver-specs.md §1](../v2/driver-specs.md).
|
||||
|
||||
## Project Split
|
||||
|
||||
Galaxy ships as three projects:
|
||||
|
||||
| Project | Target | Role |
|
||||
|---------|--------|------|
|
||||
| `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/` | .NET Standard 2.0 | IPC contracts (MessagePack records + `MessageKind` enum) referenced by both sides |
|
||||
| `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/` | .NET Framework 4.8 **x86** | Separate Windows service hosting the MXAccess COM objects, STA thread + Win32 message pump, Galaxy Repository reader, Historian SDK, runtime-probe manager |
|
||||
| `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/` | .NET 10 (matches Server) | `GalaxyProxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IRediscoverable, IHostConnectivityProbe` — loaded in-process by the Server; every call forwards over the pipe to the Host |
|
||||
|
||||
The Shared assembly is the **only** contract between the two runtimes. It carries no COM or SDK references so Proxy (net10) can reference it without dragging x86 code into the Server process.
|
||||
|
||||
## Why Out-of-Process
|
||||
|
||||
Two reasons drive the split, per `docs/v2/plan.md`:
|
||||
|
||||
1. **Bitness constraint.** MXAccess is 32-bit COM only — `ArchestrA.MxAccess.dll` in `Program Files (x86)\ArchestrA\Framework\bin` has no 64-bit variant. The main OtOpcUa Server is .NET 10 x64 (the OPC Foundation stack, SqlClient, and every other non-Galaxy driver target 64-bit). In-process hosting would force the whole Server to x86, which every other driver project would then inherit.
|
||||
2. **Tier-C stability isolation.** Galaxy is classified Tier C in [docs/v2/driver-stability.md](../v2/driver-stability.md) — the COM runtime, STA thread, Aveva Historian SDK, and SQL queries all have crash/hang modes that can take down the hosting process. Isolating the driver in its own Windows service means a COM deadlock, AccessViolation in an unmanaged Historian DLL, or a runaway SQL query never takes the Server endpoint down. The Proxy-side supervisor restarts the Host with crash-loop circuit-breaker.
|
||||
|
||||
The same Tier-C isolation story applies to FOCAS (decision record in `docs/v2/plan.md` §7), which is the second out-of-process driver.
|
||||
|
||||
## IPC Transport
|
||||
|
||||
`GalaxyProxyDriver` → `GalaxyIpcClient` → named pipe → `Galaxy.Host` pipe server.
|
||||
|
||||
- Pipe name: `otopcua-galaxy-{DriverInstanceId}` (localhost-only, no TCP surface)
|
||||
- Wire format: MessagePack-CSharp, length-prefixed frames
|
||||
- ACL: pipe is created with a DACL that grants only the Server's service identity; the Admins group is explicitly denied so a live-smoke test running from an elevated shell fails fast rather than silently bypassing the handshake
|
||||
- Handshake: Proxy presents a shared secret at `OpenSessionRequest`; Host rejects anything else with `MessageKind.OpenSessionResponse{Success=false}`
|
||||
- Heartbeat: Proxy sends a periodic ping; missed heartbeats trigger the Proxy-side crash-loop supervisor to restart the Host
|
||||
|
||||
Every capability call on `GalaxyProxyDriver` (Read, Write, Subscribe, HistoryRead*, etc.) serializes a `*Request`, awaits the matching `*Response` via a `CallAsync<TReq, TResp>` helper, and rehydrates the result into the `Core.Abstractions` shape the Server expects.
|
||||
|
||||
## STA Thread Requirement (Host-side)
|
||||
|
||||
MXAccess COM objects — `LMXProxyServer` instantiation, `Register`, `AddItem`, `AdviseSupervisory`, `Write`, and cleanup calls — must all execute on the same Single-Threaded Apartment. Calling a COM object from the wrong thread causes marshalling failures or silent data corruption.
|
||||
|
||||
`StaComThread` in the Host provides that thread with the apartment state set before the thread starts:
|
||||
|
||||
```csharp
|
||||
_thread = new Thread(ThreadEntry) { Name = "MxAccess-STA", IsBackground = true };
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
```
|
||||
|
||||
Work items queue via `RunAsync(Action)` or `RunAsync<T>(Func<T>)` into a `ConcurrentQueue<Action>` and post `WM_APP` to wake the pump. Each work item is wrapped in a `TaskCompletionSource` so callers can `await` the result from any thread — including the IPC handler thread that receives the inbound pipe request.
|
||||
|
||||
## Win32 Message Pump (Host-side)
|
||||
|
||||
COM callbacks (`OnDataChange`, `OnWriteComplete`) are delivered through the Windows message loop. `StaComThread` runs a standard Win32 message pump via P/Invoke:
|
||||
|
||||
1. `PeekMessage` primes the message queue (required before `PostThreadMessage` works)
|
||||
2. `GetMessage` blocks until a message arrives
|
||||
3. `WM_APP` drains the work queue
|
||||
4. `WM_APP + 1` drains the queue and posts `WM_QUIT` to exit the loop
|
||||
5. All other messages go through `TranslateMessage` / `DispatchMessage` for COM callback delivery
|
||||
|
||||
Without this pump MXAccess callbacks never fire and the driver delivers no live data.
|
||||
|
||||
## LMXProxyServer COM Object
|
||||
|
||||
`MxProxyAdapter` wraps the real `ArchestrA.MxAccess.LMXProxyServer` COM object behind the `IMxProxy` interface so Host unit tests can substitute a fake proxy without requiring the ArchestrA runtime. Lifecycle:
|
||||
|
||||
1. **`Register(clientName)`** — Creates a new `LMXProxyServer` instance, wires up `OnDataChange` and `OnWriteComplete` event handlers, calls `Register` to obtain a connection handle
|
||||
2. **`Unregister(handle)`** — Unwires event handlers, calls `Unregister`, releases the COM object via `Marshal.ReleaseComObject`
|
||||
|
||||
## Register / AddItem / AdviseSupervisory Pattern
|
||||
|
||||
Every MXAccess data operation follows a three-step pattern, all executed on the STA thread:
|
||||
|
||||
1. **`AddItem(handle, address)`** — Resolves a Galaxy tag reference (e.g., `TestMachine_001.MachineID`) to an integer item handle
|
||||
2. **`AdviseSupervisory(handle, itemHandle)`** — Subscribes the item for supervisory data-change callbacks
|
||||
3. The runtime begins delivering `OnDataChange` events
|
||||
|
||||
For writes, after `AddItem` + `AdviseSupervisory`, `Write(handle, itemHandle, value, securityClassification)` sends the value; `OnWriteComplete` confirms or rejects. Cleanup reverses: `UnAdviseSupervisory` then `RemoveItem`.
|
||||
|
||||
## OnDataChange and OnWriteComplete Callbacks
|
||||
|
||||
### OnDataChange
|
||||
|
||||
Fired by the COM runtime on the STA thread when a subscribed tag changes. The handler in `MxAccessClient.EventHandlers.cs`:
|
||||
|
||||
1. Maps the integer `phItemHandle` back to a tag address via `_handleToAddress`
|
||||
2. Maps the MXAccess quality code to the internal `Quality` enum
|
||||
3. Checks `MXSTATUS_PROXY` for error details and adjusts quality
|
||||
4. Converts the timestamp to UTC
|
||||
5. Constructs a `Vtq` (Value/Timestamp/Quality) and delivers it to:
|
||||
- The stored per-tag subscription callback
|
||||
- Any pending one-shot read completions
|
||||
- The global `OnTagValueChanged` event (consumed by the Host's subscription dispatcher, which packages changes into `DataChangeEventArgs` and forwards them over the pipe to `GalaxyProxyDriver.OnDataChange`)
|
||||
|
||||
### OnWriteComplete
|
||||
|
||||
Fired when the runtime acknowledges or rejects a write. The handler resolves the pending `TaskCompletionSource<bool>` for the item handle. If `MXSTATUS_PROXY.success == 0` the write is considered failed and the error detail is logged.
|
||||
|
||||
## Reconnection Logic
|
||||
|
||||
`MxAccessClient` implements automatic reconnection through two mechanisms.
|
||||
|
||||
### Monitor loop
|
||||
|
||||
`StartMonitor` launches a background task that polls at `MonitorIntervalSeconds`. On each cycle:
|
||||
|
||||
- If the state is `Disconnected` or `Error` and `AutoReconnect` is enabled, it calls `ReconnectAsync`
|
||||
- If connected and a probe tag is configured, it checks the probe staleness threshold
|
||||
|
||||
### Reconnect sequence
|
||||
|
||||
`ReconnectAsync` performs a full disconnect-then-connect cycle:
|
||||
|
||||
1. Increment the reconnect counter
|
||||
2. `DisconnectAsync` — tear down all active subscriptions (`UnAdviseSupervisory` + `RemoveItem` for each), detach COM event handlers, call `Unregister`, clear all handle mappings
|
||||
3. `ConnectAsync` — create a fresh `LMXProxyServer`, register, replay all stored subscriptions, re-subscribe the probe tag
|
||||
|
||||
Stored subscriptions (`_storedSubscriptions`) persist across reconnects. `ReplayStoredSubscriptionsAsync` iterates the stored entries and calls `AddItem` + `AdviseSupervisory` for each.
|
||||
|
||||
## Probe Tag Health Monitoring
|
||||
|
||||
A configurable probe tag (e.g., a frequently updating Galaxy attribute) serves as a connection health indicator. After connecting, the client subscribes to the probe tag and records `_lastProbeValueTime` on every `OnDataChange`. The monitor loop compares `DateTime.UtcNow - _lastProbeValueTime` against `ProbeStaleThresholdSeconds`; if the probe has not updated within the window, the connection is assumed stale and a reconnect is forced. This catches scenarios where the COM connection is technically alive but the runtime has stopped delivering data.
|
||||
|
||||
## Per-Host Runtime Status Probes (`<Host>.ScanState`)
|
||||
|
||||
Separate from the connection-level probe, the driver advises `<HostName>.ScanState` on every deployed `$WinPlatform` and `$AppEngine` in the Galaxy. These probes track per-host runtime state so the Admin UI dashboard can report "this specific Platform / AppEngine is off scan" and the driver can proactively invalidate every OPC UA variable hosted by the stopped object — preventing MXAccess from serving stale Good-quality cached values to clients who read those tags while the host is down.
|
||||
|
||||
Enabled by default via `MxAccess.RuntimeStatusProbesEnabled`; see [Configuration](../Configuration.md#mxaccess) for the two config fields.
|
||||
|
||||
### How it works
|
||||
|
||||
`GalaxyRuntimeProbeManager` lives in `Driver.Galaxy.Host` alongside the rest of the MXAccess code. It is owned by the Host's subscription dispatcher and runs a three-state machine per host (Unknown / Running / Stopped):
|
||||
|
||||
1. **Discovery** — After the Host completes `BuildAddressSpace`, the manager filters the hierarchy to rows where `CategoryId == 1` (`$WinPlatform`) or `CategoryId == 3` (`$AppEngine`) and issues `AdviseSupervisory` for `<TagName>.ScanState` on each one. Probes are driver-owned, not ref-counted against client subscriptions, and persist across address-space rebuilds via a `Sync` diff.
|
||||
2. **Transition predicate** — A probe callback is interpreted as `isRunning = vtq.Quality.IsGood() && vtq.Value is bool b && b`. Everything else (explicit `ScanState = false`, bad quality, communication errors) means **Stopped**.
|
||||
3. **On-change-only delivery** — `ScanState` is delivered only when the value actually changes. A stably Running host may go hours without a callback. `Tick()` does NOT run a starvation check on Running entries — the only time-based transition is **Unknown → Stopped** when the initial callback hasn't arrived within `RuntimeStatusUnknownTimeoutSeconds` (default 15s). This protects against a probe that fails to resolve at all without incorrectly flipping healthy long-running hosts.
|
||||
4. **Transport gating** — When `IMxAccessClient.State != Connected`, `GetSnapshot()` forces every entry to `Unknown`. The dashboard shows the Connection panel as the primary signal in that case rather than misleading operators with "every host stopped".
|
||||
5. **Subscribe failure rollback** — If `SubscribeAsync` throws for a new probe (SDK failure, broker rejection, transport error), the manager rolls back both `_byProbe` and `_probeByGobjectId` so the probe never appears in `GetSnapshot()`. Stability review 2026-04-13 Finding 1.
|
||||
|
||||
### Subtree quality invalidation on transition
|
||||
|
||||
When a host transitions **Running → Stopped**, the probe manager invokes a callback that walks `_hostedVariables[gobjectId]` — the set of every OPC UA variable transitively hosted by that Galaxy object — and sets each variable's `StatusCode` to `BadOutOfService`. **Stopped → Running** calls `ClearHostVariablesBadQuality` to reset each to `Good` so the next on-change MXAccess update repopulates the value.
|
||||
|
||||
The hosted-variables map is built once per `BuildAddressSpace` by walking each object's `HostedByGobjectId` chain up to the nearest Platform or Engine ancestor. A variable hosted by an Engine inside a Platform lands in both the Engine's list and the Platform's list, so stopping the Platform transitively invalidates every descendant Engine's variables.
|
||||
|
||||
### Read-path short-circuit (`IsTagUnderStoppedHost`)
|
||||
|
||||
The Host's Read handler checks `IsTagUnderStoppedHost(tagRef)` (a reverse-index lookup `_hostIdsByTagRef[tagRef]` → `GalaxyRuntimeProbeManager.IsHostStopped(hostId)`) before the MXAccess round-trip. When the owning host is Stopped, the handler returns a synthesized `DataValue { Value = cachedVar.Value, StatusCode = BadOutOfService }` directly without touching MXAccess. This guarantees clients see a uniform `BadOutOfService` on every descendant tag of a stopped host, regardless of whether they're reading or subscribing.
|
||||
|
||||
### Deferred dispatch — the STA deadlock
|
||||
|
||||
**Critical**: probe transition callbacks must **not** run synchronously on the STA thread that delivered the `OnDataChange`. `MarkHostVariablesBadQuality` takes the subscription dispatcher lock, which may be held by a worker thread currently inside `Read` waiting on an `_mxAccessClient.ReadAsync()` round-trip that is itself waiting for the STA thread. Classic circular wait — the first real deploy of this feature hung inside 30 seconds from exactly this pattern.
|
||||
|
||||
The fix is a deferred-dispatch queue: probe callbacks enqueue the transition onto `ConcurrentQueue<(int GobjectId, bool Stopped)>` and set the existing dispatch signal. The dispatch thread drains the queue inside its existing 100ms `WaitOne` loop — outside any locks held by the STA path — and then calls `MarkHostVariablesBadQuality` / `ClearHostVariablesBadQuality` under its own natural lock acquisition. No circular wait, no STA involvement.
|
||||
|
||||
### Dashboard and health surface
|
||||
|
||||
- Admin UI **Galaxy Runtime** panel shows per-host state with Name / Kind / State / Since / Last Error columns. Panel color is green (all Running), yellow (any Unknown, none Stopped), red (any Stopped), gray (MXAccess transport disconnected)
|
||||
- `HealthCheckService.CheckHealth` rolls overall driver health to `Degraded` when any host is Stopped
|
||||
|
||||
See [Status Dashboard](../StatusDashboard.md#galaxy-runtime) for the field table and [Configuration](../Configuration.md#mxaccess) for the config fields.
|
||||
|
||||
## Request Timeout Safety Backstop
|
||||
|
||||
Every sync-over-async site on the OPC UA stack thread that calls into Galaxy (`Read`, `Write`, address-space rebuild probe sync) is wrapped in a bounded `SyncOverAsync.WaitSync(...)` helper with timeout `MxAccess.RequestTimeoutSeconds` (default 30s). Inner `ReadTimeoutSeconds` / `WriteTimeoutSeconds` bounds on the async path are the first line of defense; the outer wrapper is a backstop so a scheduler stall, slow reconnect, or any other non-returning async path cannot park the stack thread indefinitely.
|
||||
|
||||
On timeout, the underlying task is **not** cancelled — it runs to completion on the thread pool and is abandoned. This is acceptable because Galaxy IPC clients are shared singletons and the abandoned continuation does not capture request-scoped state. The OPC UA stack receives `StatusCodes.BadTimeout` on the affected operation.
|
||||
|
||||
`ConfigurationValidator` enforces `RequestTimeoutSeconds >= 1` and warns when it is set below the inner Read/Write timeouts (operator misconfiguration). Stability review 2026-04-13 Finding 3.
|
||||
|
||||
All capability calls at the Server dispatch layer are additionally wrapped by `CapabilityInvoker` (Core/Resilience/) which runs them through a Polly pipeline keyed on `(DriverInstanceId, HostName, DriverCapability)`. `OTOPCUA0001` analyzer enforces the wrap at build time.
|
||||
|
||||
## Why Marshal.ReleaseComObject Is Needed
|
||||
|
||||
The .NET Framework runtime's garbage collector releases COM references non-deterministically. For MXAccess, delayed release can leave stale COM connections open, preventing clean re-registration. `MxProxyAdapter.Unregister` calls `Marshal.ReleaseComObject(_lmxProxy)` in a `finally` block to immediately drive the COM reference count to zero. This ensures the underlying COM server is freed before a reconnect attempt creates a new instance.
|
||||
|
||||
## Tag Discovery and Historical Data
|
||||
|
||||
Tag discovery (the Galaxy Repository SQL reader + `LocalPlatform` scope filter) is covered in [Galaxy-Repository.md](Galaxy-Repository.md). The Galaxy driver is `ITagDiscovery` for the Server's bootstrap path and `IRediscoverable` for the on-change-redeploy path.
|
||||
|
||||
Historical data access (raw, processed, at-time, events) runs against the Aveva Historian via the `aahClientManaged` SDK and is exposed through the Galaxy driver's `IHistoryProvider` implementation. See [HistoricalDataAccess.md](../HistoricalDataAccess.md).
|
||||
|
||||
## Key source files
|
||||
|
||||
Host-side (`.NET 4.8 x86`, `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/`):
|
||||
|
||||
- `Backend/MxAccess/StaComThread.cs` — STA thread and Win32 message pump
|
||||
- `Backend/MxAccess/MxAccessClient.cs` — Core client (partial)
|
||||
- `Backend/MxAccess/MxAccessClient.Connection.cs` — Connect / disconnect / reconnect
|
||||
- `Backend/MxAccess/MxAccessClient.Subscription.cs` — Subscribe / unsubscribe / replay
|
||||
- `Backend/MxAccess/MxAccessClient.ReadWrite.cs` — Read and write operations
|
||||
- `Backend/MxAccess/MxAccessClient.EventHandlers.cs` — `OnDataChange` / `OnWriteComplete` handlers
|
||||
- `Backend/MxAccess/MxAccessClient.Monitor.cs` — Background health monitor
|
||||
- `Backend/MxAccess/MxProxyAdapter.cs` — COM object wrapper
|
||||
- `Backend/MxAccess/GalaxyRuntimeProbeManager.cs` — Per-host `ScanState` probes, state machine, `IsHostStopped` lookup
|
||||
- `Backend/Historian/HistorianDataSource.cs` — `aahClientManaged` SDK wrapper (see [HistoricalDataAccess.md](../HistoricalDataAccess.md))
|
||||
- `Ipc/GalaxyIpcServer.cs` — Named-pipe server, message dispatch
|
||||
- `Domain/IMxAccessClient.cs` — Client interface
|
||||
|
||||
Shared (`.NET Standard 2.0`, `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/`):
|
||||
|
||||
- `Contracts/MessageKind.cs` — IPC message kinds (`ReadRequest`, `HistoryReadRequest`, `OpenSessionResponse`, …)
|
||||
- `Contracts/*.cs` — MessagePack DTOs for every request/response pair
|
||||
|
||||
Proxy-side (`.NET 10`, `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/`):
|
||||
|
||||
- `GalaxyProxyDriver.cs` — `IDriver`/`ITagDiscovery`/`IReadable`/`IWritable`/`ISubscribable`/`IAlarmSource`/`IHistoryProvider`/`IRediscoverable`/`IHostConnectivityProbe` implementation; every method forwards via `GalaxyIpcClient`
|
||||
- `Ipc/GalaxyIpcClient.cs` — Named-pipe client, `CallAsync<TReq, TResp>`, reconnect on broken pipe
|
||||
- `GalaxyProxySupervisor.cs` — Host-process monitor, crash-loop circuit-breaker, Host relaunch
|
||||
46
docs/drivers/README.md
Normal file
46
docs/drivers/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Drivers
|
||||
|
||||
OtOpcUa is a multi-driver OPC UA server. The Core (`ZB.MOM.WW.OtOpcUa.Core` + `Core.Abstractions` + `Server`) owns the OPC UA stack, address space, session/security/subscription machinery, resilience pipeline, and namespace kinds (Equipment + SystemPlatform). Drivers plug in through **capability interfaces** defined in `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`:
|
||||
|
||||
- `IDriver` — lifecycle (`InitializeAsync`, `ReinitializeAsync`, `ShutdownAsync`, `GetHealth`)
|
||||
- `IReadable` / `IWritable` — one-shot reads and writes
|
||||
- `ITagDiscovery` — address-space enumeration
|
||||
- `ISubscribable` — driver-pushed data-change streams
|
||||
- `IHostConnectivityProbe` — per-host reachability events
|
||||
- `IPerCallHostResolver` — multi-host drivers that route each call to a target endpoint at dispatch time
|
||||
- `IAlarmSource` — driver-emitted OPC UA A&C events
|
||||
- `IHistoryProvider` — raw / processed / at-time / events HistoryRead (see [HistoricalDataAccess.md](../HistoricalDataAccess.md))
|
||||
- `IRediscoverable` — driver-initiated address-space rebuild notifications
|
||||
|
||||
Each driver opts into only the capabilities it supports. Every async capability call at the Server dispatch layer goes through `CapabilityInvoker` (`Core/Resilience/CapabilityInvoker.cs`), which wraps it in a Polly pipeline keyed on `(DriverInstanceId, HostName, DriverCapability)`. The `OTOPCUA0001` analyzer enforces the wrap at build time. Drivers themselves never depend on Polly; they just implement the capability interface and let the Core wrap it.
|
||||
|
||||
Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs`). The registry records each type's allowed namespace kinds (`Equipment` / `SystemPlatform` / `Simulated`), its JSON Schema for `DriverConfig` / `DeviceConfig` / `TagConfig` columns, and its stability tier per [docs/v2/driver-stability.md](../v2/driver-stability.md).
|
||||
|
||||
## Ground-truth driver list
|
||||
|
||||
| Driver | Project path | Tier | Wire / library | Capabilities | Notable quirk |
|
||||
|--------|--------------|:----:|----------------|--------------|---------------|
|
||||
| [Galaxy](Galaxy.md) | `Driver.Galaxy.{Shared, Host, Proxy}` | C | MXAccess COM + `aahClientManaged` + SqlClient | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IRediscoverable, IHostConnectivityProbe | Out-of-process — Host is its own Windows service (.NET 4.8 x86 for the COM bitness constraint); Proxy talks to Host over a named pipe |
|
||||
| Modbus TCP | `Driver.Modbus` | A | NModbus-derived in-house client | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Polled subscriptions via the shared `PollGroupEngine`. DL205 PLCs are covered by `AddressFormat=DL205` (octal V/X/Y/C/T/CT translation) — no separate driver |
|
||||
| Siemens S7 | `Driver.S7` | A | [S7netplus](https://github.com/S7NetPlus/s7netplus) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Single S7netplus `Plc` instance per PLC serialized with `SemaphoreSlim` — the S7 CPU's comm mailbox is scanned at most once per cycle, so parallel reads don't help |
|
||||
| AB CIP | `Driver.AbCip` | A | libplctag CIP | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | ControlLogix / CompactLogix. Tag discovery uses the `@tags` walker to enumerate controller-scoped + program-scoped symbols; UDT member resolution via the UDT template reader |
|
||||
| AB Legacy | `Driver.AbLegacy` | A | libplctag PCCC | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | SLC 500 / MicroLogix. File-based addressing (`N7:0`, `F8:0`) — no symbol table, tag list is user-authored in the config DB |
|
||||
| TwinCAT | `Driver.TwinCAT` | B | Beckhoff `TwinCAT.Ads` (`TcAdsClient`) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | The only native-notification driver outside Galaxy — ADS delivers `ValueChangedCallback` events the driver forwards straight to `ISubscribable.OnDataChange` without polling. Symbol tree uploaded via `SymbolLoaderFactory` |
|
||||
| FOCAS | `Driver.FOCAS` | C | FANUC FOCAS2 (`Fwlib32.dll` P/Invoke) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | Tier C — FOCAS DLL has crash modes that warrant process isolation. CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map |
|
||||
| OPC UA Client | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IHostConnectivityProbe | Gateway/aggregation driver. Opens a single `Session` against a remote OPC UA server and re-exposes its address space. Owns its own `ApplicationConfiguration` (distinct from `Client.Shared`) because it's always-on with keep-alive + `TransferSubscriptions` across SDK reconnect, not an interactive CLI |
|
||||
|
||||
## Per-driver documentation
|
||||
|
||||
- **Galaxy** has its own docs in this folder because the out-of-process architecture + MXAccess COM rules + Galaxy Repository SQL + Historian + runtime probe manager don't fit a single table row:
|
||||
- [Galaxy.md](Galaxy.md) — COM bridge, STA pump, IPC, runtime probes
|
||||
- [Galaxy-Repository.md](Galaxy-Repository.md) — ZB SQL reader, `LocalPlatform` scope filter, change detection
|
||||
|
||||
- **All other drivers** share a single per-driver specification in [docs/v2/driver-specs.md](../v2/driver-specs.md) — addressing, data-type maps, connection settings, and quirks live there. That file is the authoritative per-driver reference; this index points at it rather than duplicating.
|
||||
|
||||
## Related cross-driver docs
|
||||
|
||||
- [HistoricalDataAccess.md](../HistoricalDataAccess.md) — `IHistoryProvider` dispatch, aggregate mapping, continuation points. The Galaxy driver's Aveva Historian implementation is the first; OPC UA Client forwards to the upstream server; other drivers do not implement the interface and return `BadHistoryOperationUnsupported`.
|
||||
- [AlarmTracking.md](../AlarmTracking.md) — `IAlarmSource` event model and filtering.
|
||||
- [Subscriptions.md](../Subscriptions.md) — how the Server multiplexes subscriptions onto `ISubscribable.OnDataChange`.
|
||||
- [docs/v2/driver-stability.md](../v2/driver-stability.md) — tier system (A / B / C), shared `CapabilityPolicy` defaults per tier × capability, `MemoryTracking` hybrid formula, and process-level recycle rules.
|
||||
- [docs/v2/plan.md](../v2/plan.md) — authoritative vision, architecture decisions, migration strategy.
|
||||
@@ -1,8 +1,10 @@
|
||||
# OPC UA Client Requirements
|
||||
|
||||
## Overview
|
||||
> **Revision** — Refreshed 2026-04-19 for the OtOpcUa v2 multi-driver platform (task #205). The Client surface (shared library + CLI + UI) shipped for v2 is preserved; this refresh restructures the document into numbered, directly-verifiable requirements (CLI-* and UI-* prefixes) layered on top of the existing detailed design content. Requirement coverage added for the `redundancy` command, alarm subscribe/ack round-trip, history-read, and UI tree-browser drag-to-subscribe behaviors. Original design-spec material for `ConnectionSettings`, `IOpcUaClientService`, models, and view-models is retained as reference-level details below the numbered requirements.
|
||||
|
||||
Three new .NET 10 cross-platform projects providing a shared OPC UA client library, a CLI tool, and an Avalonia desktop UI. All projects target Windows and macOS.
|
||||
Parent: [HLR-001](HighLevelReqs.md#hlr-001-opc-ua-server), [HLR-009](HighLevelReqs.md#hlr-009-transport-security-and-authentication), [HLR-013](HighLevelReqs.md#hlr-013-cluster-redundancy)
|
||||
|
||||
See also: `docs/Client.CLI.md`, `docs/Client.UI.md`.
|
||||
|
||||
## Projects
|
||||
|
||||
@@ -10,134 +12,161 @@ Three new .NET 10 cross-platform projects providing a shared OPC UA client libra
|
||||
|---------|------|---------|
|
||||
| `ZB.MOM.WW.OtOpcUa.Client.Shared` | Class library | Core OPC UA client, models, interfaces |
|
||||
| `ZB.MOM.WW.OtOpcUa.Client.CLI` | Console app | Command-line interface using CliFx |
|
||||
| `ZB.MOM.WW.OtOpcUa.Client.UI` | Avalonia app | Desktop UI with tree browser, subscriptions, alarms |
|
||||
| `ZB.MOM.WW.OtOpcUa.Client.Shared.Tests` | Test project | Unit tests for shared library |
|
||||
| `ZB.MOM.WW.OtOpcUa.Client.CLI.Tests` | Test project | Unit tests for CLI commands |
|
||||
| `ZB.MOM.WW.OtOpcUa.Client.UI.Tests` | Test project | Unit tests for UI view models |
|
||||
| `ZB.MOM.WW.OtOpcUa.Client.UI` | Avalonia app | Desktop UI |
|
||||
| `ZB.MOM.WW.OtOpcUa.Client.Shared.Tests` | Test project | Shared-library unit tests |
|
||||
| `ZB.MOM.WW.OtOpcUa.Client.CLI.Tests` | Test project | CLI command tests |
|
||||
| `ZB.MOM.WW.OtOpcUa.Client.UI.Tests` | Test project | ViewModel unit tests |
|
||||
|
||||
## Shared Requirements (Client.Shared)
|
||||
|
||||
### SHR-001: Single Service Interface
|
||||
|
||||
The Client.Shared library shall expose a single service interface `IOpcUaClientService` covering connect, disconnect, read, write, browse, subscribe, alarm-subscribe, alarm-ack, history-read-raw, history-read-aggregate, and get-redundancy-info operations.
|
||||
|
||||
### SHR-002: ConnectionSettings Model
|
||||
|
||||
The library shall expose a `ConnectionSettings` record with the fields: `EndpointUrl` (required), `FailoverUrls[]`, `Username`, `Password`, `SecurityMode` (None/Sign/SignAndEncrypt; default None), `SessionTimeoutSeconds` (default 60), `AutoAcceptCertificates` (default true), `CertificateStorePath`.
|
||||
|
||||
### SHR-003: Automatic Failover
|
||||
|
||||
The library shall monitor session keep-alive and automatically fail over across `FailoverUrls` when the primary endpoint is unreachable, emitting a `ConnectionStateChanged` event on each transition (Disconnected / Connecting / Connected / Reconnecting).
|
||||
|
||||
### SHR-004: Cross-Platform Certificate Store
|
||||
|
||||
The library shall auto-generate a client certificate on first use and store it in a cross-platform path (default `{AppData}/OtOpcUaClient/pki/`). Server certificates are auto-accepted when `AutoAcceptCertificates = true`.
|
||||
|
||||
### SHR-005: Type-Coercing Write
|
||||
|
||||
The library's `WriteValueAsync(NodeId, object)` shall read the node's current value to determine target type and coerce the input value before sending.
|
||||
|
||||
### SHR-006: UI-Thread Dispatch Neutrality
|
||||
|
||||
The library shall not assume any specific synchronization context. Events (`DataChanged`, `AlarmEvent`, `ConnectionStateChanged`) are raised on the OPC UA stack thread; the consuming CLI / UI is responsible for dispatching to its UI thread.
|
||||
|
||||
---
|
||||
|
||||
## CLI Requirements (Client.CLI)
|
||||
|
||||
### CLI-001: Command Surface
|
||||
|
||||
The CLI shall expose the following commands: `connect`, `read`, `write`, `browse`, `subscribe`, `historyread`, `alarms`, `redundancy`.
|
||||
|
||||
### CLI-002: Common Options
|
||||
|
||||
All CLI commands shall accept the options `-u, --url` (required), `-U, --username`, `-P, --password`, `-S, --security none|sign|encrypt`, `-F, --failover-urls` (comma-separated), `--verbose`.
|
||||
|
||||
### CLI-003: Connect Command
|
||||
|
||||
The `connect` command shall attempt to establish a session using the supplied options and print `Connected` plus the resolved endpoint's `ServerUriArray` and `ApplicationUri` on success, or a diagnostic error message on failure.
|
||||
|
||||
### CLI-004: Read Command
|
||||
|
||||
The `read -n <NodeId>` command shall print `NodeId`, `Value`, `StatusCode`, `SourceTimestamp`, `ServerTimestamp` one per line.
|
||||
|
||||
### CLI-005: Write Command
|
||||
|
||||
The `write -n <NodeId> -v <value>` command shall coerce the value to the node's current type (per SHR-005) and print the resulting `StatusCode`. A `Bad_UserAccessDenied` result is printed verbatim so operators see the authorization outcome.
|
||||
|
||||
### CLI-006: Browse Command
|
||||
|
||||
The `browse [-n <parent>] [-r] [-d <depth>]` command shall list child nodes under `parent` (or the `Objects` folder if omitted). `-r` enables recursion up to `-d` depth (default 1).
|
||||
|
||||
### CLI-007: Subscribe Command
|
||||
|
||||
The `subscribe -n <NodeId> -i <intervalMs>` command shall create a monitored item at `intervalMs` publishing interval, print each `DataChanged` event as `<timestamp> <nodeId> <value> <status>` until Ctrl-C, then cleanly unsubscribe.
|
||||
|
||||
### CLI-008: Historyread Command
|
||||
|
||||
The `historyread -n <NodeId> --start <utc> --end <utc> [--max <n>] [--aggregate <type> --interval <ms>]` command shall print raw values or aggregate buckets. Supported aggregate types: Average, Minimum, Maximum, Count, Start, End.
|
||||
|
||||
### CLI-009: Alarms Command
|
||||
|
||||
The `alarms [-n <source>] [-i <intervalMs>]` command shall subscribe to alarm events, print each event as `<time> <source> <condition> <severity> <state> <acked> <message>`, accept `ack <conditionId>` commands interactively, and support `refresh` to trigger `RequestConditionRefreshAsync`.
|
||||
|
||||
### CLI-010: Redundancy Command
|
||||
|
||||
The `redundancy` command shall call `GetRedundancyInfoAsync` and print `Mode`, `ServiceLevel`, `ApplicationUri`, and `ServerUris` (one per line). Suitable for redundancy-failover smoke tests.
|
||||
|
||||
### CLI-011: Logging
|
||||
|
||||
The CLI shall use Serilog console sink at `Warning` minimum by default; `--verbose` raises to `Debug`.
|
||||
|
||||
---
|
||||
|
||||
## UI Requirements (Client.UI)
|
||||
|
||||
### UI-001: Connection Panel
|
||||
|
||||
The UI shall present a top-bar connection panel with fields for Endpoint URL, Username, Password, Security mode, and a Connect / Disconnect button. The resolved `RedundancyInfo` is displayed next to the bar on successful connect.
|
||||
|
||||
### UI-002: Tree Browser
|
||||
|
||||
The UI shall present a left-pane tree browser backed by `IOpcUaClientService.BrowseAsync`, lazy-loading children on node expansion (one level per `BrowseAsync` call).
|
||||
|
||||
### UI-003: Read/Write Tab
|
||||
|
||||
The UI shall provide a Read/Write tab that auto-reads the selected tree node's current value, displays `Value` + `StatusCode` + `SourceTimestamp`, and accepts a write value with a Send button.
|
||||
|
||||
### UI-004: Subscriptions Tab
|
||||
|
||||
The UI shall provide a Subscriptions tab that lists active monitored items (columns: NodeId, Value, Status, Timestamp), supports Add and Remove, and dispatches `DataChanged` events to the Avalonia UI thread via `Dispatcher.UIThread.Post`.
|
||||
|
||||
### UI-005: Alarms Tab
|
||||
|
||||
The UI shall provide an Alarms tab that supports SubscribeAlarms / UnsubscribeAlarms / RefreshConditions commands, displays live alarm events, and supports `Acknowledge` on selected events. Acknowledgment failure (including `Bad_UserAccessDenied`) is surfaced to the user.
|
||||
|
||||
### UI-006: History Tab
|
||||
|
||||
The UI shall provide a History tab with inputs for StartTime, EndTime, MaxValues, AggregateType, Interval, a Read command, and a results table with columns (Timestamp, Value, Status).
|
||||
|
||||
### UI-007: Connection State Reflects in UI
|
||||
|
||||
All tabs shall reflect the connection state — when disconnected, all action commands are disabled; the status bar shows `Disconnected` / `Connecting` / `Connected` / `Reconnecting` tied to the `ConnectionStateChanged` event.
|
||||
|
||||
### UI-008: Cross-Platform
|
||||
|
||||
The UI shall build and run on Windows (win-x64) and macOS (osx-arm64 / osx-x64). No platform-specific OPC UA stack APIs are used.
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- .NET 10, C#
|
||||
- OPC UA: OPCFoundation.NetStandard.Opc.Ua.Client
|
||||
- OPC UA: `OPCFoundation.NetStandard.Opc.Ua.Client`
|
||||
- Logging: Serilog
|
||||
- CLI: CliFx
|
||||
- UI: Avalonia 11.x with CommunityToolkit.Mvvm
|
||||
- Tests: xUnit 3, Shouldly, Microsoft.Testing.Platform runner
|
||||
|
||||
## Client.Shared
|
||||
## Client.Shared — Design Detail
|
||||
|
||||
### ConnectionSettings Model
|
||||
### IOpcUaClientService Interface (reference)
|
||||
|
||||
```
|
||||
EndpointUrl: string (required)
|
||||
FailoverUrls: string[] (optional)
|
||||
Username: string? (optional, first-class property)
|
||||
Password: string? (optional, first-class property)
|
||||
SecurityMode: enum (None, Sign, SignAndEncrypt) — default None
|
||||
SessionTimeoutSeconds: int — default 60
|
||||
AutoAcceptCertificates: bool — default true
|
||||
CertificateStorePath: string? — default platform-appropriate location
|
||||
```
|
||||
**Lifecycle:** `ConnectAsync(ConnectionSettings)`, `DisconnectAsync()`, `IsConnected`.
|
||||
|
||||
### IOpcUaClientService Interface
|
||||
**Read/Write:** `ReadValueAsync(NodeId)`, `WriteValueAsync(NodeId, object)`.
|
||||
|
||||
Single service interface covering all OPC UA operations:
|
||||
**Browse:** `BrowseAsync(NodeId? parent)` → `BrowseResult[]` (NodeId, DisplayName, NodeClass, HasChildren); lazy-load compatible.
|
||||
|
||||
**Lifecycle:**
|
||||
- `ConnectAsync(ConnectionSettings)` — connect to server, handle endpoint discovery, security, auth
|
||||
- `DisconnectAsync()` — close session cleanly
|
||||
- `IsConnected` property
|
||||
**Subscribe:** `SubscribeAsync(NodeId, int intervalMs)`, `UnsubscribeAsync(NodeId)`, `event DataChanged(NodeId, DataValue)`.
|
||||
|
||||
**Read/Write:**
|
||||
- `ReadValueAsync(NodeId)` — returns DataValue (value, status, timestamps)
|
||||
- `WriteValueAsync(NodeId, object value)` — auto-detects target type, returns StatusCode
|
||||
**Alarms:** `SubscribeAlarmsAsync(NodeId? source, int intervalMs)`, `UnsubscribeAlarmsAsync()`, `AcknowledgeAsync(conditionId, comment)`, `RequestConditionRefreshAsync()`, `event AlarmEvent(AlarmEventArgs)`.
|
||||
|
||||
**Browse:**
|
||||
- `BrowseAsync(NodeId? parent)` — returns list of BrowseResult (NodeId, DisplayName, NodeClass)
|
||||
- Lazy-load compatible (browse one level at a time)
|
||||
**History:** `HistoryReadRawAsync(NodeId, start, end, maxValues)`, `HistoryReadAggregateAsync(NodeId, start, end, AggregateType, intervalMs)`.
|
||||
|
||||
**Subscribe:**
|
||||
- `SubscribeAsync(NodeId, int intervalMs)` — create monitored item subscription
|
||||
- `UnsubscribeAsync(NodeId)` — remove monitored item
|
||||
- `event DataChanged` — fires on value change with (NodeId, DataValue)
|
||||
|
||||
**Alarms:**
|
||||
- `SubscribeAlarmsAsync(NodeId? source, int intervalMs)` — subscribe to alarm events
|
||||
- `UnsubscribeAlarmsAsync()` — remove alarm subscription
|
||||
- `RequestConditionRefreshAsync()` — trigger condition refresh
|
||||
- `event AlarmEvent` — fires on alarm state change with AlarmEventArgs
|
||||
|
||||
**History:**
|
||||
- `HistoryReadRawAsync(NodeId, DateTime start, DateTime end, int maxValues)` — raw historical values
|
||||
- `HistoryReadAggregateAsync(NodeId, DateTime start, DateTime end, AggregateType, double intervalMs)` — aggregated values
|
||||
|
||||
**Redundancy:**
|
||||
- `GetRedundancyInfoAsync()` — returns RedundancyInfo (mode, service level, server URIs, app URI)
|
||||
|
||||
**Failover:**
|
||||
- Automatic failover across FailoverUrls with keep-alive monitoring
|
||||
- `event ConnectionStateChanged` — fires on connect/disconnect/failover
|
||||
**Redundancy:** `GetRedundancyInfoAsync()` → `RedundancyInfo` (Mode, ServiceLevel, ServerUris, ApplicationUri).
|
||||
|
||||
### Models
|
||||
|
||||
- `BrowseResult`: NodeId, DisplayName, NodeClass, HasChildren
|
||||
- `AlarmEventArgs`: SourceName, ConditionName, Severity, Message, Retain, ActiveState, AckedState, Time
|
||||
- `RedundancyInfo`: Mode, ServiceLevel, ServerUris, ApplicationUri
|
||||
- `ConnectionState`: enum (Disconnected, Connecting, Connected, Reconnecting)
|
||||
- `AggregateType`: enum (Average, Minimum, Maximum, Count, Start, End)
|
||||
- `BrowseResult` — NodeId, DisplayName, NodeClass, HasChildren
|
||||
- `AlarmEventArgs` — SourceName, ConditionName, Severity, Message, Retain, ActiveState, AckedState, Time
|
||||
- `RedundancyInfo` — Mode, ServiceLevel, ServerUris, ApplicationUri
|
||||
- `ConnectionState` — enum (Disconnected, Connecting, Connected, Reconnecting)
|
||||
- `AggregateType` — enum (Average, Minimum, Maximum, Count, Start, End)
|
||||
|
||||
### Type Conversion
|
||||
---
|
||||
|
||||
Port the existing `ConvertValue` logic from the CLI tool: reads the current node value to determine the target type, then coerces the input value.
|
||||
|
||||
### Certificate Management
|
||||
|
||||
- Cross-platform certificate store path (default: `{AppData}/LmxOpcUaClient/pki/`)
|
||||
- Auto-generate client certificate on first use
|
||||
- Auto-accept untrusted server certificates (configurable)
|
||||
|
||||
### Logging
|
||||
|
||||
Serilog with `ILogger` passed via constructor or `Log.ForContext<T>()`. No sinks configured in the library — consumers configure sinks.
|
||||
|
||||
## Client.CLI
|
||||
|
||||
### Commands
|
||||
|
||||
All 8 commands:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `connect` | Test server connectivity |
|
||||
| `read` | Read a node value |
|
||||
| `write` | Write a value to a node |
|
||||
| `browse` | Browse address space (with depth/recursive) |
|
||||
| `subscribe` | Monitor node for value changes |
|
||||
| `historyread` | Read historical data (raw + aggregates) |
|
||||
| `alarms` | Subscribe to alarm events |
|
||||
| `redundancy` | Query redundancy state |
|
||||
|
||||
All commands use the shared `IOpcUaClientService`. Each command:
|
||||
1. Creates `ConnectionSettings` from CLI options
|
||||
2. Creates `OpcUaClientService`
|
||||
3. Calls the appropriate method
|
||||
4. Formats and prints results
|
||||
|
||||
### Common Options (all commands)
|
||||
|
||||
- `-u, --url` (required): Endpoint URL
|
||||
- `-U, --username`: Username
|
||||
- `-P, --password`: Password
|
||||
- `-S, --security`: Security mode (none/sign/encrypt)
|
||||
- `-F, --failover-urls`: Comma-separated failover endpoints
|
||||
|
||||
### Logging
|
||||
|
||||
Serilog console sink at Warning level by default, with `--verbose` flag for Debug.
|
||||
|
||||
## Client.UI
|
||||
|
||||
### Window Layout
|
||||
## Client.UI — View Layout (reference)
|
||||
|
||||
Single-window Avalonia application:
|
||||
|
||||
@@ -146,82 +175,43 @@ Single-window Avalonia application:
|
||||
│ [Endpoint URL] [User] [Pass] [Security▼] [Connect] │
|
||||
│ Redundancy: Mode=Warm ServiceLevel=200 AppUri=... │
|
||||
├──────────────┬──────────────────────────────────────────┤
|
||||
│ │ ┌─Read/Write─┬─Subscriptions─┬─Alarms─┬─History─┐│
|
||||
│ Address │ │ Node: ns=3;s=Tag.Attr ││
|
||||
│ Space │ │ Value: 42.5 ││
|
||||
│ Tree │ │ Status: Good ││
|
||||
│ Browser │ │ [Write: ____] [Send] ││
|
||||
│ │ │ ││
|
||||
│ (lazy-load) │ │ ││
|
||||
│ │ └──────────────────────────────────────┘│
|
||||
│ │ ┌Read/Write┬Subscriptions┬Alarms┬History┐│
|
||||
│ Address │ │ Node: ns=3;s=Tag.Attr ││
|
||||
│ Space │ │ Value: 42.5 Status: Good ││
|
||||
│ Tree │ │ [Write: ____] [Send] ││
|
||||
│ Browser │ └───────────────────────────────────────┘│
|
||||
├──────────────┴──────────────────────────────────────────┤
|
||||
│ Status: Connected | Session: abc123 | 3 subscriptions │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Views and ViewModels (CommunityToolkit.Mvvm)
|
||||
### ViewModels (CommunityToolkit.Mvvm)
|
||||
|
||||
**MainWindowViewModel:**
|
||||
- Connection settings properties (bound to top bar inputs)
|
||||
- ConnectCommand / DisconnectCommand (RelayCommand)
|
||||
- ConnectionState property
|
||||
- RedundancyInfo property
|
||||
- SelectedTreeNode property
|
||||
- StatusMessage property
|
||||
|
||||
**BrowseTreeViewModel:**
|
||||
- Root nodes collection (ObservableCollection)
|
||||
- Lazy-load children on expand via `BrowseAsync`
|
||||
- TreeNodeViewModel: NodeId, DisplayName, NodeClass, Children, IsExpanded, HasChildren
|
||||
|
||||
**ReadWriteViewModel:**
|
||||
- SelectedNode (from tree selection)
|
||||
- CurrentValue, Status, SourceTimestamp
|
||||
- WriteValue input + WriteCommand
|
||||
- Auto-read on node selection
|
||||
|
||||
**SubscriptionsViewModel:**
|
||||
- ActiveSubscriptions collection (ObservableCollection)
|
||||
- AddSubscription / RemoveSubscription commands
|
||||
- Live value updates dispatched to UI thread
|
||||
- Columns: NodeId, Value, Status, Timestamp
|
||||
|
||||
**AlarmsViewModel:**
|
||||
- AlarmEvents collection (ObservableCollection)
|
||||
- SubscribeCommand / UnsubscribeCommand / RefreshCommand
|
||||
- MonitoredNode property
|
||||
- Live alarm events dispatched to UI thread
|
||||
|
||||
**HistoryViewModel:**
|
||||
- SelectedNode (from tree selection)
|
||||
- StartTime, EndTime, MaxValues, AggregateType, Interval
|
||||
- ReadCommand
|
||||
- Results collection (ObservableCollection)
|
||||
- Columns: Timestamp, Value, Status
|
||||
|
||||
### UI Thread Dispatch
|
||||
|
||||
All events from `IOpcUaClientService` must be dispatched to the Avalonia UI thread via `Dispatcher.UIThread.Post()` before updating ObservableCollections.
|
||||
- `MainWindowViewModel` — connection fields, connect/disconnect commands, `ConnectionState`, `RedundancyInfo`, `SelectedTreeNode`, `StatusMessage`.
|
||||
- `BrowseTreeViewModel` — root collection (`ObservableCollection<TreeNodeViewModel>`), lazy-load on expand.
|
||||
- `ReadWriteViewModel` — auto-read on selection, `WriteValue` + `WriteCommand`.
|
||||
- `SubscriptionsViewModel` — `ActiveSubscriptions`, `AddSubscription`, `RemoveSubscription`, live `DataChanged` dispatch to UI thread.
|
||||
- `AlarmsViewModel` — `AlarmEvents`, Subscribe / Unsubscribe / Refresh / Acknowledge commands.
|
||||
- `HistoryViewModel` — `StartTime`, `EndTime`, `MaxValues`, `AggregateType`, `Interval`, `ReadCommand`, `Results`.
|
||||
|
||||
## Test Projects
|
||||
|
||||
### Client.Shared.Tests
|
||||
- ConnectionSettings validation
|
||||
- Type conversion (ConvertValue)
|
||||
- BrowseResult model construction
|
||||
- AlarmEventArgs model construction
|
||||
- `ConnectionSettings` validation
|
||||
- Type conversion
|
||||
- `BrowseResult` / `AlarmEventArgs` / `RedundancyInfo` model construction
|
||||
- FailoverUrl parsing
|
||||
|
||||
### Client.CLI.Tests
|
||||
- Command option parsing (via CliFx test infrastructure)
|
||||
- Output formatting
|
||||
- Output formatting for each command
|
||||
|
||||
### Client.UI.Tests
|
||||
- ViewModel property change notifications
|
||||
- Command can-execute logic
|
||||
- Tree node lazy-load behavior (with mocked IOpcUaClientService)
|
||||
- ViewModel property-change notifications
|
||||
- Command `CanExecute` logic
|
||||
- Tree lazy-load behavior (with mocked `IOpcUaClientService`)
|
||||
|
||||
### Test Framework
|
||||
- xUnit 3 with Microsoft.Testing.Platform runner
|
||||
- Shouldly for assertions
|
||||
- No live OPC UA server required — mock IOpcUaClientService for unit tests
|
||||
- Shouldly
|
||||
- No live OPC UA server required — mock `IOpcUaClientService` for unit tests
|
||||
|
||||
@@ -1,106 +1,113 @@
|
||||
# Galaxy Repository — Component Requirements
|
||||
# Galaxy Driver — Galaxy Repository Requirements
|
||||
|
||||
Parent: [HLR-002](HighLevelReqs.md#hlr-002-galaxy-hierarchy-as-opc-ua-address-space), [HLR-005](HighLevelReqs.md#hlr-005-dynamic-address-space-rebuild)
|
||||
> **Revision** — Refreshed 2026-04-19 for the OtOpcUa v2 multi-driver platform (task #205). Scope clarified: this document is **Galaxy-driver-specific**. Galaxy is one of seven drivers in the OtOpcUa platform; the requirements below describe the SQL-side of the Galaxy driver (hierarchy/attribute/change-detection queries against the ZB database) that backs the Galaxy driver's `ITagDiscovery.DiscoverAsync` and `IRediscoverable` implementations. All Galaxy-specific SQL runs inside `OtOpcUa.Galaxy.Host` (.NET 4.8 x86 Windows service); the in-server `Driver.Galaxy.Proxy` calls it over a named pipe. For platform-wide tag discovery requirements see `OpcUaServerReqs.md` OPC-002. For deeper spec see `docs/GalaxyRepository.md` and `docs/v2/driver-specs.md`.
|
||||
|
||||
Parent: [HLR-002](HighLevelReqs.md#hlr-002-multi-driver-plug-in-model), [HLR-003](HighLevelReqs.md#hlr-003-address-space-composition-per-namespace), [HLR-006](HighLevelReqs.md#hlr-006-change-detection-and-rediscovery)
|
||||
|
||||
Driver scope: Galaxy only. Namespace kind: `SystemPlatform`.
|
||||
|
||||
## GR-001: Hierarchy Extraction
|
||||
|
||||
The system shall query the Galaxy Repository database to extract all deployed objects with their parent-child containment relationships, contained names, and tag names.
|
||||
The Galaxy driver's `ITagDiscovery.DiscoverAsync` implementation shall query the ZB Galaxy Repository database to extract all deployed objects with their parent-child containment relationships, contained names, and tag names.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Executes `queries/hierarchy.sql` against the ZB database.
|
||||
- Executes `queries/hierarchy.sql` against the ZB database from within `OtOpcUa.Galaxy.Host`.
|
||||
- Returns a list of objects with: `gobject_id`, `tag_name`, `contained_name`, `browse_name`, `parent_gobject_id`, `is_area`.
|
||||
- Objects with `parent_gobject_id = 0` are children of the root ZB node.
|
||||
- Objects with `parent_gobject_id = 0` become children of the root ZB node inside the `SystemPlatform` namespace.
|
||||
- Only deployed, non-template objects matching the category filter (areas, engines, user-defined objects, etc.) are returned.
|
||||
- Query completes within 10 seconds on a typical Galaxy (hundreds of objects). Log a Warning if it takes longer.
|
||||
- Query completes within 10 seconds on a typical Galaxy (hundreds of objects). Log Warning if it takes longer.
|
||||
|
||||
### Details
|
||||
|
||||
- Results are ordered by `parent_gobject_id, tag_name` for deterministic tree building.
|
||||
- If the query returns zero rows, log a Warning (Galaxy may have no deployed objects, or the DB connection may be misconfigured).
|
||||
- Orphan detection: if a row references a `parent_gobject_id` that does not exist in the result set and is not 0, log a Warning and skip that node.
|
||||
- Empty result → Warning logged (Galaxy may have no deployed objects, or the DB connection may be misconfigured).
|
||||
- Orphan detection: a row referencing a non-existent `parent_gobject_id` (and not 0) is skipped with a Warning.
|
||||
- Streamed to the core via `IAddressSpaceBuilder.AddFolder` / `AddObject` calls over the Galaxy named pipe; no in-memory full-tree buffering on the Host side.
|
||||
|
||||
---
|
||||
|
||||
## GR-002: Attribute Extraction
|
||||
|
||||
The system shall query user-defined (dynamic) attributes for deployed objects, including data type, array flag, and array dimensions.
|
||||
The Galaxy driver shall query user-defined (dynamic) attributes for deployed objects, including data type, array flag, and array dimensions.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Executes `queries/attributes.sql` using the template chain CTE to resolve inherited attributes.
|
||||
- Returns: `gobject_id`, `tag_name`, `attribute_name`, `full_tag_reference`, `mx_data_type`, `is_array`, `array_dimension`, `security_classification`.
|
||||
- Attributes starting with `_` are filtered out by the query.
|
||||
- `array_dimension` is correctly extracted from the `mx_value` hex bytes (positions 13-16, little-endian uint16).
|
||||
- `array_dimension` is extracted from the `mx_value` hex bytes (positions 13-16, little-endian uint16).
|
||||
|
||||
### Details
|
||||
|
||||
- CTE recursion depth is limited to 10 levels (per the query). This is sufficient for Galaxy template hierarchies.
|
||||
- If `mx_data_type` is null or not in the known set (1-8, 13-16), default to String.
|
||||
- If `gobject_id` from an attribute row does not match any hierarchy object, skip that attribute (object may not be deployed).
|
||||
- CTE recursion depth is limited to 10 levels.
|
||||
- `mx_data_type` not in the known set (1-8, 13-16) defaults to String.
|
||||
- `gobject_id` that doesn't match a hierarchy object is skipped (object may not be deployed).
|
||||
- Each emitted attribute is reported via `DriverAttributeInfo` to the core through `IAddressSpaceBuilder.AddVariable`.
|
||||
|
||||
---
|
||||
|
||||
## GR-003: Change Detection
|
||||
## GR-003: Change Detection and IRediscoverable
|
||||
|
||||
The system shall poll `galaxy.time_of_last_deploy` at a configurable interval to detect when a new deployment has occurred.
|
||||
The Galaxy driver shall implement `IRediscoverable` by polling `galaxy.time_of_last_deploy` on a configurable interval to detect when a new deployment has occurred.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Polls `SELECT time_of_last_deploy FROM galaxy` at a configurable interval (`GalaxyRepository:ChangeDetectionIntervalSeconds`, default 30 seconds).
|
||||
- Polls `SELECT time_of_last_deploy FROM galaxy` at a configurable interval (`Galaxy:ChangeDetectionIntervalSeconds`, default 30 seconds).
|
||||
- Compares the returned timestamp to the last known value stored in memory.
|
||||
- If different, triggers a rebuild (re-run hierarchy + attributes queries, notify OPC UA server).
|
||||
- First poll after startup always triggers an initial build.
|
||||
- If the query fails (SQL timeout, connection error), log Warning and retry at next interval. Do not trigger a rebuild on failure.
|
||||
- If different, raises the `IRediscoverable.RediscoveryNeeded` signal so the core re-runs `ITagDiscovery.DiscoverAsync` and surgically rebuilds the Galaxy namespace subtree (per OPC-017).
|
||||
- First poll after startup always triggers an initial discovery.
|
||||
- Query failure → Warning logged; no rediscovery triggered; retry at next interval.
|
||||
|
||||
### Details
|
||||
|
||||
- Polling runs on a background timer thread, not blocking the STA thread.
|
||||
- `time_of_last_deploy` is a datetime column. Compare using exact equality (not range).
|
||||
- Polling runs on a background `Task` inside `OtOpcUa.Galaxy.Host`, not on the STA message-pump thread.
|
||||
- `time_of_last_deploy` is a `datetime` column; compared using exact equality (not a range).
|
||||
- Signal delivery to the Proxy happens via a server-push message on the Galaxy named pipe.
|
||||
|
||||
---
|
||||
|
||||
## GR-004: Rebuild on Change
|
||||
## GR-004: Rediscovery Data Flow
|
||||
|
||||
When a deployment change is detected, the system shall re-query hierarchy and attributes and provide the updated structure to the OPC UA server for address space rebuild.
|
||||
On a deployment change, the Galaxy driver shall re-query hierarchy + attributes and stream the updated structure to the core for surgical namespace rebuild.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- On change detection, re-query both hierarchy and attributes.
|
||||
- Provide the new data set to the OPC UA server component for address space replacement.
|
||||
- Log at Information level: "Galaxy deployment change detected. Rebuilding address space. ({ObjectCount} objects, {AttributeCount} attributes)".
|
||||
- Log total rebuild time at Information level.
|
||||
- If the re-query fails, log Error and keep the existing address space (do not clear it).
|
||||
- On change signal, re-run `GR-001` (hierarchy) and `GR-002` (attributes) queries.
|
||||
- Stream the new tree to the core via `IAddressSpaceBuilder` over the named pipe.
|
||||
- Log at Information level: `"Galaxy deployment change detected. Rebuilding. ({ObjectCount} objects, {AttributeCount} attributes)"`.
|
||||
- Log total rediscovery duration at Information level.
|
||||
- On re-query failure: Error logged; existing Galaxy subtree is retained.
|
||||
|
||||
### Details
|
||||
|
||||
- Rebuild is not atomic from the DB perspective — hierarchy and attributes are two separate queries. This is acceptable; deployment is an infrequent operation.
|
||||
- Raise an event/callback that the OPC UA server subscribes to: `OnGalaxyChanged(hierarchyData, attributeData)`.
|
||||
- Rediscovery is not atomic from the DB perspective — hierarchy and attributes are two separate queries. Acceptable; Galaxy deployment is an infrequent operation.
|
||||
- The core owns the diff/surgical apply per OPC-017; the Galaxy driver only streams the new authoritative tree.
|
||||
|
||||
---
|
||||
|
||||
## GR-005: Connection Configuration
|
||||
|
||||
Database connection parameters shall be configurable via appsettings.json (connection string using Windows Authentication by default).
|
||||
Galaxy DB connection parameters shall be configurable via environment variables passed from the `OtOpcUa.Galaxy.Host` supervisor at spawn time.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Connection string in `appsettings.json` under `GalaxyRepository:ConnectionString`.
|
||||
- Default: `Server=localhost;Database=ZB;Integrated Security=true` (Windows Auth).
|
||||
- ADO.NET `SqlConnection` used for queries (.NET Framework 4.8 built-in).
|
||||
- Connection string via `OTOPCUA_GALAXY_ZB_CONN` environment variable.
|
||||
- Default: `Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;` (Windows Auth).
|
||||
- ADO.NET `SqlConnection` used for queries (.NET Framework 4.8).
|
||||
- Connection is opened per-query (not kept open). Connection pooling handles efficiency.
|
||||
- If the initial connection test at startup fails, log Error with the connection string and continue attempting (change detection polls will keep retrying).
|
||||
- If the initial connection test at startup fails, log Error with the connection string sanitized and continue attempting (change-detection polls keep retrying).
|
||||
|
||||
### Details
|
||||
|
||||
- Command timeout: configurable via `GalaxyRepository:CommandTimeoutSeconds`, default 30 seconds.
|
||||
- No ORM. Raw ADO.NET with `SqlCommand` and `SqlDataReader`. SQL text is embedded as constants (not dynamically constructed).
|
||||
- Command timeout: `Galaxy:CommandTimeoutSeconds` in Config DB driver JSON (default 30 seconds).
|
||||
- No ORM. Raw ADO.NET with `SqlCommand` and `SqlDataReader`. SQL text embedded as constants.
|
||||
|
||||
---
|
||||
|
||||
## GR-006: Query Safety
|
||||
|
||||
All SQL queries shall be static read-only SELECT statements. No writes to the Galaxy Repository database.
|
||||
All Galaxy SQL queries shall be static read-only SELECT statements. No writes to the Galaxy Repository database.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
@@ -112,10 +119,23 @@ All SQL queries shall be static read-only SELECT statements. No writes to the Ga
|
||||
|
||||
## GR-007: Startup Validation
|
||||
|
||||
On startup, the Galaxy Repository component shall validate database connectivity.
|
||||
On startup, the Galaxy driver's DB component inside `OtOpcUa.Galaxy.Host` shall validate database connectivity.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Execute a simple test query (`SELECT 1`) against the configured database.
|
||||
- If the database is unreachable, log an Error but do not prevent service startup.
|
||||
- The service runs in degraded mode (empty address space) until the database becomes available and the next change detection poll succeeds.
|
||||
- Execute a simple test query (`SELECT 1`) against the configured Galaxy DB.
|
||||
- If the database is unreachable, log Error but do not prevent Host startup.
|
||||
- The Galaxy driver runs in degraded mode (empty SystemPlatform namespace) until the database becomes available and the next change-detection poll succeeds.
|
||||
- In degraded mode the Galaxy driver instance reports `DriverHealth.Unavailable`, causing its Polly circuit state to be open until the first successful discovery.
|
||||
|
||||
---
|
||||
|
||||
## GR-008: Capability Wrapping
|
||||
|
||||
All calls into the Galaxy DB component from the Proxy side shall route through `CapabilityInvoker.InvokeAsync(DriverCapability.Discover, …)`.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- `Driver.Galaxy.Proxy.DiscoverAsync` is a thin capability-invoker call that sends a MessagePack request over the named pipe to the Host's DB component.
|
||||
- Roslyn analyzer **OTOPCUA0001** validates there are no direct discovery calls bypassing the invoker.
|
||||
- Polly pipeline for `DriverCapability.Discover` on the Galaxy driver instance carries Timeout + Retry + CircuitBreaker.
|
||||
|
||||
@@ -1,47 +1,94 @@
|
||||
# High-Level Requirements
|
||||
|
||||
> **Revision** — Refreshed 2026-04-19 for the OtOpcUa v2 multi-driver platform (task #205). The original 2025 text described a single-process Galaxy/MXAccess server called LmxOpcUa. Today the project is the **OtOpcUa** multi-driver OPC UA platform deployed as three cooperating processes (Server, Admin, Galaxy.Host). The Galaxy integration is one of seven shipped drivers. HLR-001 through HLR-008 have been rewritten driver-agnostically; HLR-009 has been retired (the embedded Status Dashboard is superseded by the Admin UI). HLR-010 through HLR-017 are new and cover plug-in drivers, resilience, Config DB / draft-publish, cluster redundancy, fleet-wide identifier uniqueness, Admin UI, audit logging, metrics, and the Roslyn capability-wrapping analyzer.
|
||||
|
||||
## HLR-001: OPC UA Server
|
||||
|
||||
The system shall expose an OPC UA server endpoint that OPC UA clients can connect to for browsing, reading, and writing Galaxy tag data.
|
||||
The system shall expose an OPC UA server endpoint that OPC UA clients can connect to for browsing, reading, writing, subscribing, acknowledging alarms, and reading historical values. Data is sourced from one or more **driver instances** that plug into the common core; OPC UA clients see a single unified address space per endpoint regardless of how many drivers are active behind it.
|
||||
|
||||
## HLR-002: Galaxy Hierarchy as OPC UA Address Space
|
||||
## HLR-002: Multi-Driver Plug-In Model
|
||||
|
||||
The system shall build an OPC UA address space that mirrors the System Platform Galaxy object hierarchy, using contained names for browse structure and tag names for runtime data access.
|
||||
The system shall support pluggable driver modules that bind to specific data sources. v2.0 ships seven drivers: Galaxy (AVEVA System Platform via MXAccess), Modbus TCP (including DL205 via `AddressFormat=DL205`), Allen-Bradley CIP (ControlLogix/CompactLogix), Allen-Bradley Legacy (SLC/MicroLogix via PCCC), Siemens S7, Beckhoff TwinCAT (ADS), FANUC FOCAS, and OPC UA Client (aggregation/gateway). Drivers implement only the capability interfaces (`IDriver`, `ITagDiscovery`, `IReadable`, `IWritable`, `ISubscribable`, `IAlarmSource`, `IHistoryProvider`, `IHostConnectivityProbe`, `IPerCallHostResolver`, `IRediscoverable`) defined in `ZB.MOM.WW.OtOpcUa.Core.Abstractions` that apply to their protocol. Multiple instances of the same driver type are supported; each instance binds to its own OPC UA namespace index.
|
||||
|
||||
## HLR-003: MXAccess Runtime Data Access
|
||||
## HLR-003: Address Space Composition per Namespace
|
||||
|
||||
The system shall use the MXAccess toolkit to subscribe to, read, and write Galaxy tag attribute values at runtime on behalf of connected OPC UA clients.
|
||||
The system shall build the OPC UA address space by composing per-driver subtrees into a single endpoint. Each driver instance owns one namespace and registers its nodes via the core-provided `IAddressSpaceBuilder` streaming API. The Galaxy driver continues to mirror the deployed ArchestrA object hierarchy (contained-name browse paths) in a namespace of kind `SystemPlatform`. Native-protocol drivers populate a namespace of kind `Equipment` whose browse structure conforms to the canonical 5-level Unified Namespace (`Enterprise / Site / Area / Line / Equipment / Signal`).
|
||||
|
||||
## HLR-004: Data Type Mapping
|
||||
|
||||
The system shall map Galaxy attribute data types (mx_data_type) to appropriate OPC UA built-in types, including support for array attributes.
|
||||
Each driver shall map its native data types to OPC UA built-in types via `DriverDataType` conversions, including support for arrays (ValueRank=1 with ArrayDimensions). Type mapping is driver-specific — `docs/DataTypeMapping.md` covers Galaxy/MXAccess; each other driver's spec in `docs/v2/driver-specs.md` covers its own mapping. Unknown/unmapped driver types shall default to String per the driver's spec.
|
||||
|
||||
## HLR-005: Dynamic Address Space Rebuild
|
||||
## HLR-005: Live Data Access
|
||||
|
||||
The system shall detect Galaxy deployment changes (via `galaxy.time_of_last_deploy`) and rebuild the OPC UA address space to reflect the current deployed state.
|
||||
For every data-path operation (read, write, subscribe notification, alarm event, history read, tag rediscovery, host connectivity probe), the system shall route the call through the capability interface owned by the target driver instance. Reads and subscriptions shall deliver a `DataValueSnapshot` carrying value, OPC UA `StatusCode`, and source timestamp regardless of the underlying protocol. Every async capability invocation at dispatch shall pass through `Core.Resilience.CapabilityInvoker`.
|
||||
|
||||
## HLR-006: Windows Service Hosting
|
||||
## HLR-006: Change Detection and Rediscovery
|
||||
|
||||
The system shall run as a Windows service (via TopShelf) with support for install, uninstall, and interactive console modes.
|
||||
Drivers whose backend has a native change signal (e.g. Galaxy's `time_of_last_deploy`, OPC UA Client receiving `ServerStatusChange`) shall implement the optional `IRediscoverable` interface so the core can rebuild only the affected subtree. Drivers whose tag set is static relative to a published config generation are not required to implement `IRediscoverable`; their address-space structure changes only via a new published Config DB generation (see HLR-012).
|
||||
|
||||
## HLR-007: Logging
|
||||
## HLR-007: Service Hosting
|
||||
|
||||
The system shall log operational events to rolling daily log files using Serilog.
|
||||
The system shall be deployed as three cooperating Windows services:
|
||||
|
||||
## HLR-008: Connection Resilience
|
||||
- **OtOpcUa.Server** — .NET 10 x64, `Microsoft.Extensions.Hosting` + `AddWindowsService`, hosts all non-Galaxy drivers in-process and the OPC UA endpoint.
|
||||
- **OtOpcUa.Admin** — .NET 10 x64 Blazor Server web app, hosts the admin UI, SignalR hubs for live updates, `/metrics` Prometheus endpoint, and audit log writers.
|
||||
- **OtOpcUa.Galaxy.Host** — .NET Framework 4.8 x86 (TopShelf), hosts MXAccess COM + Galaxy Repository SQL + Historian plugin. Talks to `Driver.Galaxy.Proxy` inside `OtOpcUa.Server` via a named pipe (MessagePack over length-prefixed frames, per-process shared secret, SID-restricted ACL).
|
||||
|
||||
The system shall automatically reconnect to MXAccess after connection loss, replaying active subscriptions upon reconnect.
|
||||
## HLR-008: Logging
|
||||
|
||||
## HLR-009: Status Dashboard
|
||||
The system shall log operational events to rolling daily file sinks using Serilog on every process. Plain-text is on by default; structured JSON (CompactJsonFormatter) is opt-in via `Serilog:WriteJson = true` so SIEMs (Splunk, Datadog) can ingest without a regex parser.
|
||||
|
||||
The system shall host an embedded HTTP status dashboard (similar to the LmxProxy dashboard) providing at-a-glance operational visibility including connection state, health, subscription statistics, and operation metrics.
|
||||
## HLR-009: Transport Security and Authentication
|
||||
|
||||
The system shall support configurable OPC UA transport-security profiles (`None`, `Basic256Sha256-Sign`, `Basic256Sha256-SignAndEncrypt`, `Aes128_Sha256_RsaOaep-Sign`, `Aes128_Sha256_RsaOaep-SignAndEncrypt`, `Aes256_Sha256_RsaPss-Sign`, `Aes256_Sha256_RsaPss-SignAndEncrypt`) resolved at startup by `SecurityProfileResolver`. UserName-token authentication shall be validated against LDAP (production: Active Directory; dev: GLAuth). The server certificate is always created even for `None`-only deployments because UserName token encryption depends on it.
|
||||
|
||||
## HLR-010: Per-Driver-Instance Resilience
|
||||
|
||||
Every async capability call at dispatch shall pass through `Core.Resilience.CapabilityInvoker`, which runs a Polly v8 pipeline keyed on `(DriverInstanceId, HostName, DriverCapability)`. Retry and circuit-breaker strategies are per capability per decision #143: Read / Discover / Probe / Subscribe / AlarmSubscribe / HistoryRead retry automatically; Write and AlarmAcknowledge do **not** retry unless the tag or capability is explicitly marked with `WriteIdempotentAttribute`. A driver-instance circuit-breaker trip sets Bad quality on that instance's nodes only; other drivers are unaffected (decision #144 — per-host Polly isolation).
|
||||
|
||||
## HLR-011: Config DB and Draft/Publish
|
||||
|
||||
Cluster topology, driver instances, namespaces, UNS hierarchy, equipment, tags, node ACLs, poll groups, and role grants shall live in a central MSSQL Config DB, not in `appsettings.json`. Changes accumulate in a draft generation that is validated and then atomically published. Each published generation gets a monotonically increasing `GenerationNumber` scoped per cluster. Nodes poll the DB for new published generations and diff-apply surgically against an atomic snapshot. `appsettings.json` is reduced to bootstrap-only fields (Config DB connection, NodeId, ClusterId, LDAP, security profile, redundancy role, logging, local cache path).
|
||||
|
||||
## HLR-012: Local Cache Fallback
|
||||
|
||||
Each node shall maintain a sealed LiteDB local cache of the most recent successfully applied generation. If the central Config DB is unreachable at startup, the node shall boot from its cached generation and log a warning. Cache reads are the Polly `Fallback` leg of the Config DB pipeline.
|
||||
|
||||
## HLR-013: Cluster Redundancy
|
||||
|
||||
The system shall support non-transparent OPC UA redundancy via 2-node clusters sharing a Config DB generation. `RedundancyCoordinator` + `ServiceLevelCalculator` compute a dynamic OPC UA `ServiceLevel` reflecting role (Primary/Secondary), publish state (current generation applied vs mid-apply), health (driver circuit-breaker state), and apply-lease state. Clients select an endpoint by `ServerUriArray` + `ServiceLevel` per the OPC UA spec; there is no VIP or load balancer. Single-node deployments use the same model with `NodeCount = 1`.
|
||||
|
||||
## HLR-014: Fleet-Wide Identifier Uniqueness
|
||||
|
||||
Equipment identifiers that integrate with external systems (`ZTag` for ERP, `SAPID` for SAP PM) shall be unique fleet-wide (across all clusters), not just within a cluster. The Admin UI enforces this at draft-publish time via the `ExternalIdReservation` table, which reserves external IDs across clusters so two clusters cannot publish the same ZTag or SAPID. `EquipmentUuid` is immutable and globally unique (UUIDv4). `EquipmentId` and `MachineCode` are unique within a cluster.
|
||||
|
||||
## HLR-015: Admin UI Operator Surface
|
||||
|
||||
The system shall provide a Blazor Server Admin UI (`OtOpcUa.Admin`) as the sole write path into the Config DB. Capabilities include: cluster + node management, driver-instance CRUD with schemaless JSON editors, UNS drag-and-drop hierarchy editor, CSV-driven equipment import with fleet-wide external-id reservation, draft/publish with a 6-section diff viewer (Drivers / Namespaces / UNS / Equipment / Tags / ACLs), node-ACL editor producing a permission trie, LDAP role grants, redundancy tab, live cluster-generation state via SignalR, audit log viewer. Users authenticate via cookie-auth over LDAP bind; three admin roles (`ConfigViewer`, `ConfigEditor`, `FleetAdmin`) gate UI operations.
|
||||
|
||||
## HLR-016: Audit Logging
|
||||
|
||||
Every publish event and every ACL / role-grant change shall produce an immutable audit log row in the Config DB via `AuditLogService` with the acting principal, timestamp, action, before/after generation numbers, and affected entity ids. Audit rows are never mutated or deleted.
|
||||
|
||||
## HLR-017: Prometheus Metrics
|
||||
|
||||
The Admin service shall expose a `/metrics` endpoint using OpenTelemetry → Prometheus. Core / Server shall emit driver health (per `DriverInstanceId`), Polly circuit-breaker states (per `DriverInstanceId` + `HostName` + `DriverCapability`), capability-call duration histograms, subscription counts, session counts, memory-tracking gauges (Phase 6.1), publish durations, and Config-DB apply-status gauges.
|
||||
|
||||
## HLR-018: Roslyn Analyzer OTOPCUA0001
|
||||
|
||||
All direct call sites to capability-interface methods (`IReadable.ReadAsync`, `IWritable.WriteAsync`, `ITagDiscovery.DiscoverAsync`, `ISubscribable.SubscribeAsync`, `IAlarmSource.SubscribeAlarmsAsync` / `AcknowledgeAsync`, `IHistoryProvider.*`, `IHostConnectivityProbe.*`) made outside `Core.Resilience.CapabilityInvoker` shall produce Roslyn diagnostic **OTOPCUA0001** at build time. The analyzer is shipped in `ZB.MOM.WW.OtOpcUa.Analyzers` and referenced by every project that could host a capability call, guaranteeing that resilience cannot be accidentally bypassed.
|
||||
|
||||
## Retired HLRs
|
||||
|
||||
- **HLR-009 (Status Dashboard)** — retired. Superseded by the Admin UI (HLR-015). See `docs/v2/admin-ui.md`.
|
||||
|
||||
## Component-Level Requirements
|
||||
|
||||
Detailed requirements are broken out into the following documents:
|
||||
|
||||
- [OPC UA Server Requirements](OpcUaServerReqs.md)
|
||||
- [MXAccess Client Requirements](MxAccessClientReqs.md)
|
||||
- [Galaxy Repository Requirements](GalaxyRepositoryReqs.md)
|
||||
- [Service Host Requirements](ServiceHostReqs.md)
|
||||
- [Status Dashboard Requirements](StatusDashboardReqs.md)
|
||||
- [Galaxy Driver — Repository Requirements](GalaxyRepositoryReqs.md) (Galaxy driver only)
|
||||
- [Galaxy Driver — MXAccess Client Requirements](MxAccessClientReqs.md) (Galaxy driver only)
|
||||
- [Service Host Requirements](ServiceHostReqs.md) (all three processes)
|
||||
- [Client Requirements](ClientRequirements.md) (Client CLI + Client UI)
|
||||
- [Status Dashboard Requirements](StatusDashboardReqs.md) (retired — pointer only)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# MXAccess Client — Component Requirements
|
||||
# Galaxy Driver — MXAccess Client Requirements
|
||||
|
||||
Parent: [HLR-003](HighLevelReqs.md#hlr-003-mxaccess-runtime-data-access), [HLR-008](HighLevelReqs.md#hlr-008-connection-resilience)
|
||||
> **Revision** — Refreshed 2026-04-19 for the OtOpcUa v2 multi-driver platform (task #205). Scope narrowed: this document covers the MXAccess surface **inside `OtOpcUa.Galaxy.Host`** (.NET Framework 4.8 x86 Windows service). The in-server `Driver.Galaxy.Proxy` implements the `IReadable` / `IWritable` / `ISubscribable` / `IAlarmSource` / `IHistoryProvider` capability interfaces and routes every wire call through the named pipe to this Host process. The STA thread + reconnect playback + subscription refcount requirements from v1 are preserved; what changed is where they live (Host service, not the Server process). MXA-010 (proxy-side wrapping) and MXA-011 (pipe ACL / shared secret) are new.
|
||||
|
||||
Parent: [HLR-002](HighLevelReqs.md#hlr-002-multi-driver-plug-in-model), [HLR-005](HighLevelReqs.md#hlr-005-live-data-access), [HLR-007](HighLevelReqs.md#hlr-007-service-hosting)
|
||||
|
||||
Driver scope: Galaxy only. Process scope: `OtOpcUa.Galaxy.Host` (Host side) and `Driver.Galaxy.Proxy` (server-side forwarder).
|
||||
|
||||
## MXA-001: STA Thread with Message Pump
|
||||
|
||||
@@ -8,165 +12,194 @@ All MXAccess COM objects shall be created and called on a dedicated STA thread r
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- A dedicated thread is created with `ApartmentState.STA` before any MXAccess COM objects are instantiated.
|
||||
- The thread runs a Win32 message pump using `GetMessage`/`TranslateMessage`/`DispatchMessage` loop.
|
||||
- A dedicated thread is created with `ApartmentState.STA` before any MXAccess COM object is instantiated; implementation lives in `StaPump` inside `OtOpcUa.Galaxy.Host`.
|
||||
- The thread runs a Win32 message pump using `GetMessage` / `TranslateMessage` / `DispatchMessage`.
|
||||
- Work items are marshalled to the STA thread via `PostThreadMessage(WM_APP)` and a concurrent queue.
|
||||
- The STA thread processes work items between message pump iterations.
|
||||
- All COM object creation (`LMXProxyServer` constructor), method calls, and event callbacks happen on this thread.
|
||||
- All COM object creation (`LMXProxyServer`), method calls, and event callbacks happen on this thread.
|
||||
- Thread name `Galaxy.Sta` (for diagnostics).
|
||||
|
||||
### Details
|
||||
|
||||
- Thread name: `MxAccess-STA` (for diagnostics).
|
||||
- If the STA thread dies unexpectedly, log Fatal and trigger service shutdown. Do not attempt to create a replacement thread (COM objects on the dead thread are unrecoverable).
|
||||
- `RunAsync(Action)` method returns a `Task` that completes when the action executes on the STA thread. Callers can `await` it.
|
||||
- If the STA thread dies unexpectedly, log Fatal and trigger Host service shutdown. The supervisor restarts the Host under its driver-stability policy (`docs/v2/driver-stability.md`). COM objects on the dead thread are unrecoverable; no in-process recovery is attempted.
|
||||
- `RunAsync(Action)` returns a `Task` that completes when the action executes on the STA thread. Callers can `await` it.
|
||||
|
||||
---
|
||||
|
||||
## MXA-002: Connection Lifecycle
|
||||
|
||||
The client shall support Register/Unregister lifecycle with the LMXProxyServer COM object, tracking the connection handle.
|
||||
The Host shall support Register/Unregister lifecycle with the `LMXProxyServer` COM object, tracking the connection handle.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- `Register(clientName)` is called on the STA thread and returns a positive connection handle on success.
|
||||
- If Register returns handle <= 0, throw with descriptive error.
|
||||
- Handle ≤ 0 → descriptive error thrown; Host reports `DriverHealth.Unavailable` via the pipe so the Proxy reports Bad quality to the core.
|
||||
- `Unregister(handle)` is called during disconnect after all subscriptions are removed.
|
||||
- Client name: configurable via `MxAccess:ClientName`, default `LmxOpcUa`. Must be unique per MXAccess registration.
|
||||
- Client name comes from `OTOPCUA_GALAXY_CLIENT_NAME` environment variable; default `OtOpcUa-Galaxy.Host`. Must be unique per MXAccess registration (a cluster's Primary and Secondary each get their own client-name suffix via node override).
|
||||
- Connection state transitions: Disconnected → Connecting → Connected → Disconnecting → Disconnected (and Error from any state).
|
||||
|
||||
### Details
|
||||
|
||||
- `ConnectedSince` timestamp (UTC) is recorded after successful Register.
|
||||
- `ReconnectCount` is tracked for diagnostics and dashboard display.
|
||||
- State change events are raised for dashboard and health check consumption.
|
||||
- `ConnectedSince` (UTC) recorded after successful Register.
|
||||
- `ReconnectCount` tracked for diagnostics and `/metrics`.
|
||||
- State changes are emitted over the pipe as `DriverHealth` updates.
|
||||
|
||||
---
|
||||
|
||||
## MXA-003: Tag Subscription
|
||||
|
||||
The client shall support subscribing to tags via AddItem + AdviseSupervisory, receiving value updates through OnDataChange callbacks.
|
||||
The Host shall support subscribing to tags via AddItem + AdviseSupervisory, receiving value updates through OnDataChange callbacks.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Subscribe sequence: `AddItem(handle, address)` returns item handle, then `AdviseSupervisory(handle, itemHandle)` starts the subscription.
|
||||
- `OnDataChange` callback delivers value, quality (integer), timestamp, and MXSTATUS_PROXY array.
|
||||
- `OnDataChange` callback delivers value, quality, timestamp, and MXSTATUS_PROXY array.
|
||||
- Item address format: `tag_name.AttributeName` for scalars, `tag_name.AttributeName[]` for whole arrays.
|
||||
- If AddItem fails (e.g., tag does not exist), log Warning and return failure to caller.
|
||||
- Bidirectional maps of `address ↔ itemHandle` are maintained for callback resolution.
|
||||
- AddItem failure → Warning logged, failure propagated over the pipe to the Proxy.
|
||||
- Bidirectional maps of `address ↔ itemHandle` maintained for callback resolution.
|
||||
- Multi-client refcounting: two Proxy-side subscribe calls for the same address produce one MXAccess subscription; refcount decrement on the last unsubscribe triggers `UnAdvise` / `RemoveItem`.
|
||||
|
||||
### Details
|
||||
|
||||
- Use `AdviseSupervisory` (not `Advise`) because this is a background service with no interactive user session. AdviseSupervisory allows secured/verified writes without user authentication.
|
||||
- Stored subscriptions dictionary maps address to callback for reconnect replay.
|
||||
- On reconnect, all entries in stored subscriptions are re-subscribed (AddItem + AdviseSupervisory with new handles).
|
||||
- `AdviseSupervisory` (not `Advise`) is used because this is a background service without an interactive user session.
|
||||
- Stored subscriptions dictionary maps address → callback for reconnect replay.
|
||||
- On reconnect, every entry in stored subscriptions is re-subscribed (AddItem + AdviseSupervisory with new handles).
|
||||
|
||||
---
|
||||
|
||||
## MXA-004: Tag Read/Write
|
||||
|
||||
The client shall support synchronous-style read and write operations, marshalled to the STA thread, with configurable timeouts.
|
||||
The Host shall support synchronous-style read and write operations, marshalled to the STA thread, with configurable timeouts.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Read: implemented as subscribe-get-first-value-unsubscribe pattern (AddItem → AdviseSupervisory → wait for OnDataChange → UnAdvise → RemoveItem).
|
||||
- Read pattern: prefer cached subscription value; fall back to subscribe-get-first-value-unsubscribe (AddItem → AdviseSupervisory → wait for OnDataChange → UnAdvise → RemoveItem).
|
||||
- Write: AddItem → AdviseSupervisory → `Write()` → await `OnWriteComplete` callback → cleanup.
|
||||
- Read timeout: configurable via `MxAccess:ReadTimeoutSeconds`, default 5 seconds.
|
||||
- Write timeout: configurable via `MxAccess:WriteTimeoutSeconds`, default 5 seconds. On timeout, log Warning and return timeout error.
|
||||
- Concurrent operation limit: configurable semaphore via `MxAccess:MaxConcurrentOperations`, default 10.
|
||||
- Read timeout: `Galaxy:ReadTimeoutSeconds` in driver config (default 5 seconds) — enforced on the Host side in addition to the Proxy-side Polly `Timeout` leg.
|
||||
- Write timeout: `Galaxy:WriteTimeoutSeconds` (default 5 seconds) — enforced similarly.
|
||||
- Concurrent operation limit: configurable semaphore (`Galaxy:MaxConcurrentOperations`, default 10).
|
||||
- All operations marshalled to the STA thread.
|
||||
|
||||
### Details
|
||||
|
||||
- Write uses security classification -1 (no security). Galaxy runtime handles security enforcement.
|
||||
- `OnWriteComplete` callback: check MXSTATUS_PROXY `success` field. If 0, extract detail code and propagate error.
|
||||
- COM exceptions (`COMException` with HRESULT) are caught and translated to meaningful error messages.
|
||||
- Write uses security classification `-1` (no security). Galaxy runtime enforces security; OtOpcUa authorization is enforced server-side before the call ever reaches the pipe (per OPC-014 `AuthorizationGate`).
|
||||
- `OnWriteComplete`: check `MXSTATUS_PROXY.success`. If 0, extract detail code and propagate as an error over the pipe.
|
||||
- COM exceptions translated to meaningful error messages.
|
||||
|
||||
---
|
||||
|
||||
## MXA-005: Auto-Reconnect
|
||||
|
||||
The client shall monitor connection health and automatically reconnect on failure, replaying all stored subscriptions after reconnect.
|
||||
The Host shall monitor connection health and automatically reconnect on failure, replaying all stored subscriptions after reconnect.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Monitor loop runs on a background thread, checking connection health at configurable interval (`MxAccess:MonitorIntervalSeconds`, default 5 seconds).
|
||||
- If disconnected, attempt reconnect. On success, replay all stored subscriptions.
|
||||
- On reconnect failure, log Warning and retry at next interval (no exponential backoff — reconnect as quickly as possible on a plant-floor service).
|
||||
- Monitor loop runs on a background thread at `Galaxy:MonitorIntervalSeconds` (default 5 seconds).
|
||||
- On disconnect, attempt reconnect. On success, replay all stored subscriptions.
|
||||
- On reconnect failure, log Warning and retry at next interval (no exponential backoff inside the Host; the Proxy-side Polly pipeline handles cross-process backoff against pipe failures).
|
||||
- Reconnect count is incremented on each successful reconnect.
|
||||
- Monitor loop is cancellable (for clean shutdown).
|
||||
- Monitor loop is cancellable for clean Host shutdown.
|
||||
|
||||
### Details
|
||||
|
||||
- Reconnect cleans up old COM objects before creating new ones.
|
||||
- After reconnect, probe subscription is re-established first, then stored subscriptions.
|
||||
- No max retry limit — keep trying indefinitely until service is stopped.
|
||||
- After reconnect, probe subscription (MXA-006) is re-established first, then stored subscriptions.
|
||||
- No max retry limit — keep trying indefinitely until the Host service is stopped.
|
||||
|
||||
---
|
||||
|
||||
## MXA-006: Probe-Based Health Monitoring
|
||||
|
||||
The client shall optionally subscribe to a configurable probe tag and use OnDataChange callback staleness to detect silent connection failures.
|
||||
The Host shall optionally subscribe to a configurable probe tag and use OnDataChange callback staleness to detect silent connection failures.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Subscribe to a configurable probe tag (a known-good Galaxy attribute that changes periodically).
|
||||
- Probe tag address configured via `Galaxy:ProbeTag`. If unset, probe monitoring is disabled.
|
||||
- Track `_lastProbeValueTime` (UTC) updated on each OnDataChange for the probe tag.
|
||||
- If `DateTime.UtcNow - _lastProbeValueTime > staleThreshold`, force disconnect and reconnect.
|
||||
- Probe tag address: configurable via `MxAccess:ProbeTag`. If not configured, probe monitoring is disabled.
|
||||
- Stale threshold: configurable via `MxAccess:ProbeStaleThresholdSeconds`, default 60 seconds.
|
||||
- Stale threshold: `Galaxy:ProbeStaleThresholdSeconds` (default 60 seconds).
|
||||
- Implements `IHostConnectivityProbe` on the Proxy side so the core's `CapabilityInvoker` records probe outcomes with `DriverCapability.Probe` telemetry.
|
||||
|
||||
### Details
|
||||
|
||||
- The probe tag should be an attribute that the Galaxy runtime updates regularly (e.g., a platform heartbeat or area-level timestamp). The specific tag is site-dependent.
|
||||
- After forced reconnect, reset `_lastProbeValueTime` to `DateTime.UtcNow` to give the new connection a full threshold window.
|
||||
- The probe tag should be an attribute the Galaxy runtime updates regularly (platform heartbeat, area timestamp). Specific tag is site-dependent.
|
||||
- After forced reconnect, reset `_lastProbeValueTime` to `DateTime.UtcNow`.
|
||||
|
||||
---
|
||||
|
||||
## MXA-007: COM Cleanup
|
||||
|
||||
On disconnect or disposal, the client shall unwire event handlers, unadvise/remove all items, unregister, and release COM objects via Marshal.ReleaseComObject.
|
||||
On disconnect or disposal, the Host shall unwire event handlers, unadvise/remove all items, unregister, and release COM objects via `Marshal.ReleaseComObject`.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Cleanup order: UnAdvise all active subscriptions → RemoveItem all items → unwire OnDataChange and OnWriteComplete event handlers → Unregister → `Marshal.ReleaseComObject`.
|
||||
- Cleanup order: UnAdvise all active subscriptions → RemoveItem all items → unwire OnDataChange and OnWriteComplete handlers → Unregister → `Marshal.ReleaseComObject`.
|
||||
- On dispose: run disconnect if still connected, then dispose STA thread.
|
||||
- Each cleanup step is wrapped in try/catch (cleanup must not throw).
|
||||
- After cleanup: handle maps are cleared, pending write TCS entries are abandoned, COM reference is set to null.
|
||||
- Each cleanup step wrapped in try/catch (cleanup must not throw).
|
||||
- After cleanup: handle maps cleared, pending write TCS entries abandoned, COM reference set to null.
|
||||
|
||||
### Details
|
||||
|
||||
- `_storedSubscriptions` is NOT cleared on disconnect (preserved for reconnect replay). Only cleared on Dispose.
|
||||
- Event handlers must be unwired BEFORE Unregister, or callbacks may fire on a dead object.
|
||||
- `Marshal.ReleaseComObject` in a finally block, always, even if earlier steps fail.
|
||||
- Stored subscriptions are NOT cleared on disconnect (preserved for reconnect replay). Only cleared on Dispose.
|
||||
- Event handlers unwired BEFORE Unregister (else callbacks may fire on a dead object).
|
||||
- `Marshal.ReleaseComObject` in a `finally` block, always.
|
||||
|
||||
---
|
||||
|
||||
## MXA-008: Operation Metrics
|
||||
|
||||
The MXAccess client shall record timing and success/failure for Read, Write, and Subscribe operations.
|
||||
The MXAccess Host shall record timing and success/failure for Read, Write, and Subscribe operations.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Each operation records: duration (ms), success/failure.
|
||||
- Metrics are available for the status dashboard: count, success rate, avg/min/max/P95 latency.
|
||||
- Uses a rolling 1000-entry buffer for percentile calculation.
|
||||
- Metrics are exposed via a queryable interface consumed by the status report service.
|
||||
|
||||
### Details
|
||||
|
||||
- Uses an `ITimingScope` pattern: `using (var scope = metrics.BeginOperation("read")) { ... }` for automatic timing and success tracking.
|
||||
- Metrics are periodically logged at Debug level for diagnostics.
|
||||
- Each operation records duration (ms) + success/failure.
|
||||
- Metrics exposed over the pipe to the Proxy, which re-publishes them via OpenTelemetry → Prometheus under `DriverInstanceId = "galaxy-*"`, `HostName = "galaxy.host"`.
|
||||
- Rolling 1000-entry buffer for percentile calculation.
|
||||
- Uses an `ITimingScope` pattern: `using (var scope = metrics.BeginOperation("read")) { ... }`.
|
||||
|
||||
---
|
||||
|
||||
## MXA-009: Error Code Translation
|
||||
|
||||
The client shall translate known MXAccess error codes from MXSTATUS_PROXY.detail into human-readable messages for logging and OPC UA status propagation.
|
||||
The Host shall translate known MXAccess error codes from `MXSTATUS_PROXY.detail` into human-readable messages for logging and OPC UA status propagation.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Error 1008 → "User lacks security permission"
|
||||
- Error 1012 → "Secured write required (one signature)"
|
||||
- Error 1013 → "Verified write required (two signatures)"
|
||||
- Unknown error codes are logged with their numeric value.
|
||||
- Translated messages are included in OPC UA StatusCode descriptions and log entries.
|
||||
- Unknown error codes logged with their numeric value.
|
||||
- Translated messages flow back through the pipe and surface in OPC UA `StatusCode` descriptions and Server logs.
|
||||
- Errors 1008 / 1012 / 1013 on write operations map to `Bad_UserAccessDenied` at the OPC UA surface.
|
||||
|
||||
---
|
||||
|
||||
## MXA-010: Proxy-Side Capability Wrapping
|
||||
|
||||
`Driver.Galaxy.Proxy` shall implement the capability interfaces as thin forwarders that serialize every call through the named pipe and route every call through `CapabilityInvoker`.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- `Driver.Galaxy.Proxy` implements `IDriver` + `IReadable` + `IWritable` + `ISubscribable` + `ITagDiscovery` + `IRediscoverable` + `IAlarmSource` + `IHistoryProvider` + `IHostConnectivityProbe`.
|
||||
- Each implementation uses `CapabilityInvoker.InvokeAsync(DriverCapability.<...>, …)` — direct pipe calls bypassing the invoker are caught by Roslyn **OTOPCUA0001**.
|
||||
- Each method serializes a MessagePack request frame, sends over the pipe, awaits the response frame, deserializes, returns.
|
||||
- Pipe disconnect mid-call → `CapabilityInvoker`'s circuit breaker counts the failure; sustained disconnect opens the circuit and Galaxy nodes surface Bad quality until the pipe reconnects.
|
||||
- Proxy tolerates Host service restarts — it automatically reconnects and replays subscription setup (parallel to MXA-005 but across the IPC boundary).
|
||||
|
||||
---
|
||||
|
||||
## MXA-011: Pipe Security
|
||||
|
||||
The named pipe between Proxy and Host shall be restricted to the Server's runtime principal via SID-based ACL and authenticated with a per-process shared secret.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Pipe name from `OTOPCUA_GALAXY_PIPE` environment variable; default `OtOpcUaGalaxy`.
|
||||
- Allowed SID passed as `OTOPCUA_ALLOWED_SID` — only the declared principal (typically the Server service account) can open the pipe; `Administrators` is explicitly NOT granted (per the `project_galaxy_host_installed` memory note).
|
||||
- Shared secret passed via `OTOPCUA_GALAXY_SECRET` at spawn time; the Proxy must present the matching secret on the opening handshake.
|
||||
- Secret is process-scoped (regenerated per Host restart) and never persisted to disk or Config DB.
|
||||
- Pipe ACL denials are logged as Warning with the rejected principal SID.
|
||||
|
||||
### Details
|
||||
|
||||
- Environment variables are passed by the supervisor launching the Host (`docs/v2/driver-stability.md`).
|
||||
- Dev-box secret is stored at `.local/galaxy-host-secret.txt` for NSSM-wrapped development runs (memory note: `project_galaxy_host_installed`).
|
||||
|
||||
@@ -1,234 +1,266 @@
|
||||
# OPC UA Server — Component Requirements
|
||||
|
||||
Parent: [HLR-001](HighLevelReqs.md#hlr-001-opc-ua-server), [HLR-002](HighLevelReqs.md#hlr-002-galaxy-hierarchy-as-opc-ua-address-space), [HLR-004](HighLevelReqs.md#hlr-004-data-type-mapping)
|
||||
> **Revision** — Refreshed 2026-04-19 for the OtOpcUa v2 multi-driver platform (task #205). OPC-001…OPC-013 have been rewritten driver-agnostically — they now describe how the core OPC UA server composes multiple driver subtrees, enforces authorization, and invokes capabilities through the Polly-wrapped dispatch path. OPC-014 through OPC-022 are new and cover capability dispatch, per-host Polly isolation, idempotence-aware write retry, `AuthorizationGate`, `ServiceLevel` reporting, the alarm surface, history surface, server-certificate management, and the transport-security profile matrix. Galaxy-specific behavior has been moved out to `GalaxyRepositoryReqs.md` and `MxAccessClientReqs.md`.
|
||||
|
||||
Parent: [HLR-001](HighLevelReqs.md#hlr-001-opc-ua-server), [HLR-003](HighLevelReqs.md#hlr-003-address-space-composition-per-namespace), [HLR-009](HighLevelReqs.md#hlr-009-transport-security-and-authentication), [HLR-010](HighLevelReqs.md#hlr-010-per-driver-instance-resilience), [HLR-013](HighLevelReqs.md#hlr-013-cluster-redundancy)
|
||||
|
||||
## OPC-001: Server Endpoint
|
||||
|
||||
The OPC UA server shall listen on a configurable TCP port (default 4840) using the OPC Foundation .NET Standard stack.
|
||||
The OPC UA server shall listen on a configurable TCP endpoint using the OPC Foundation .NET Standard stack and expose a single endpoint URL per cluster node.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Server starts and accepts TCP connections on the configured port.
|
||||
- Port is read from `appsettings.json` under `OpcUa:Port`; defaults to 4840 if absent.
|
||||
- Endpoint URL format: `opc.tcp://<hostname>:<port>/LmxOpcUa`.
|
||||
- If the port is in use at startup, log an Error and fail to start (do not silently pick another port).
|
||||
- Security policy: None (no certificate validation). This is an internal plant-floor service.
|
||||
- Endpoint URL comes from `ClusterNode.EndpointUrl` in the Config DB (default form `opc.tcp://<hostname>:<port>/OtOpcUa`).
|
||||
- `ApplicationName` and `ApplicationUri` come from `ClusterNode` fields; `ApplicationUri` is unique per node so redundancy `ServerUriArray` entries are distinguishable.
|
||||
- Port defaults to 4840. If the port is in use at startup the server shall log Error and fail to start (no silent port reassignment).
|
||||
- Uses `OPCFoundation.NetStandard.Opc.Ua.Server` NuGet.
|
||||
- Endpoint URL logged at Information level on startup.
|
||||
|
||||
### Details
|
||||
|
||||
- Configurable items: port (default 4840), endpoint path (default `/LmxOpcUa`), server application name (default `LmxOpcUa`).
|
||||
- Server shall use the `OPCFoundation.NetStandard.Opc.Ua.Server` NuGet package.
|
||||
- On startup, log the endpoint URL at Information level.
|
||||
- Node-local `appsettings.json` only carries the `Config DB connection + NodeId + ClusterId` bootstrap — actual endpoint topology comes from the Config DB per HLR-011.
|
||||
|
||||
---
|
||||
|
||||
## OPC-002: Address Space Structure
|
||||
## OPC-002: Address Space Composition
|
||||
|
||||
The server shall create folder nodes for areas and object nodes for automation objects, organized in the same parent-child hierarchy as the Galaxy.
|
||||
The server shall compose an address space by mounting each active driver instance's subtree under a dedicated OPC UA namespace.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- The root folder node has BrowseName `ZB` (hardcoded Galaxy name).
|
||||
- Objects where `is_area = 1` are created as FolderType nodes (organizational).
|
||||
- Objects where `is_area = 0` are created as BaseObjectType nodes.
|
||||
- Parent-child relationships use Organizes references (for areas) and HasComponent references (for contained objects).
|
||||
- A client browsing Root → Objects → ZB → DEV → TestArea → TestMachine_001 → DelmiaReceiver sees the same structure as `gr/layout.md`.
|
||||
|
||||
### Details
|
||||
|
||||
- NodeIds use a string-based identifier scheme: `ns=1;s=<tag_name>` for object nodes, `ns=1;s=<tag_name>.<attribute_name>` for variable nodes.
|
||||
- Infrastructure objects (AppEngines, Platforms) are included in the tree but may have no variable children.
|
||||
- When `contained_name` is null or empty, fall back to `tag_name` as the BrowseName.
|
||||
- Each `DriverInstance` in the current published generation registers one `IDriver` implementation in the core.
|
||||
- Each driver's `ITagDiscovery.DiscoverAsync` result is streamed into the core via `IAddressSpaceBuilder` — `AddFolder` / `AddVariable` calls; the driver does not buffer the whole tree.
|
||||
- Each driver instance gets its own namespace index; `NamespaceUri` comes from the `Namespace` row in the Config DB.
|
||||
- Each cluster has at most one namespace per `Kind` (`Equipment`, `SystemPlatform`, future `Simulated`); enforced by UNIQUE on `(ClusterId, Kind)` in the DB.
|
||||
- Galaxy driver subtree preserves the contained-name browse structure from the deployed Galaxy (moved to `GalaxyRepositoryReqs.md`).
|
||||
- Equipment-kind drivers populate the canonical 5-level UNS structure (`Enterprise/Site/Area/Line/Equipment/Signal`).
|
||||
|
||||
---
|
||||
|
||||
## OPC-003: Variable Nodes for Attributes
|
||||
## OPC-003: Variable Nodes and Access Levels
|
||||
|
||||
Each user-defined attribute on a deployed object shall be represented as an OPC UA variable node under its parent object node.
|
||||
Each tag produced by a driver's `ITagDiscovery` shall become an OPC UA variable node.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Each row from `attributes.sql` creates one variable node under the matching object node (matched by `gobject_id`).
|
||||
- Variable node BrowseName and DisplayName are set to `attribute_name`.
|
||||
- Variable node stores `full_tag_reference` as its runtime MXAccess address.
|
||||
- Variable node AccessLevel is set based on the attribute's `security_classification` per the mapping in `gr/data_type_mapping.md`.
|
||||
- FreeAccess (0), Operate (1), Tune (4), Configure (5) → AccessLevel = CurrentRead | CurrentWrite (3).
|
||||
- SecuredWrite (2), VerifiedWrite (3), ViewOnly (6) → AccessLevel = CurrentRead (1).
|
||||
- Objects with no user-defined attributes still appear as object nodes with zero children.
|
||||
|
||||
### Details
|
||||
|
||||
- Security classification determines the OPC UA AccessLevel and UserAccessLevel attributes on each variable node. The OPC UA stack enforces read-only access for nodes with CurrentRead-only access level.
|
||||
- Attributes whose names start with `_` are already filtered by the SQL query.
|
||||
- Variable node `BrowseName` and `DisplayName` come from `DriverAttributeInfo`.
|
||||
- `DataType` is resolved from `DriverDataType` per each driver's spec in `docs/v2/driver-specs.md`.
|
||||
- `AccessLevel` and `UserAccessLevel` are derived from the tag's `SecurityClassification` and the session's effective permissions walked through the node-ACL permission trie (see OPC-017 `AuthorizationGate`).
|
||||
- Scalar attributes produce `ValueRank = Scalar`; array attributes produce `ValueRank = OneDimension` with `ArrayDimensions` set from the driver's attribute info.
|
||||
|
||||
---
|
||||
|
||||
## OPC-004: Browse Name Translation
|
||||
## OPC-004: Namespace Index Allocation
|
||||
|
||||
Browse names shall use contained names (human-readable, scoped to parent). The server shall internally translate browse paths to tag_name references for MXAccess operations.
|
||||
The server shall register one OPC UA namespace per active driver instance.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- A variable node browsed as `ZB/DEV/TestArea/TestMachine_001/DelmiaReceiver/DownloadPath` correctly translates to MXAccess reference `DelmiaReceiver_001.DownloadPath`.
|
||||
- Translation uses the `tag_name` stored on the parent object node, not the browse path.
|
||||
- No runtime path parsing — the mapping is baked into each node at build time.
|
||||
|
||||
### Details
|
||||
|
||||
- Each variable node stores its `full_tag_reference` (e.g., `DelmiaReceiver_001.DownloadPath`) at address-space build time. Read/write operations use this stored reference directly.
|
||||
- Namespace index 0 remains the standard OPC UA namespace.
|
||||
- Each driver instance's `Namespace.Uri` becomes a registered namespace; its index is assigned deterministically at startup from the published generation's driver ordering.
|
||||
- All variable NodeIds use the driver's namespace index; NodeId identifiers are string-shaped and stable across restarts of the same generation.
|
||||
- Namespace index reshuffles are a publish-time concern; clients reconciling server-relative NodeIds must re-resolve namespace URIs after a new generation is applied.
|
||||
|
||||
---
|
||||
|
||||
## OPC-005: Data Type Mapping
|
||||
## OPC-005: Read Operations
|
||||
|
||||
Variable nodes shall use OPC UA data types mapped from Galaxy mx_data_type values per the mapping in `gr/data_type_mapping.md`.
|
||||
The server shall fulfill OPC UA `Read` requests by invoking `IReadable.ReadAsync` on the target driver instance, dispatched through `CapabilityInvoker`.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Every `mx_data_type` value in the mapping table produces the correct OPC UA DataType NodeId on the variable node.
|
||||
- Unknown/unmapped `mx_data_type` values default to String (i=12).
|
||||
- ElapsedTime (type 7) maps to Double representing seconds.
|
||||
|
||||
### Details
|
||||
|
||||
- Full mapping table in `gr/data_type_mapping.md`.
|
||||
- DateTime conversion: Galaxy may store local time; convert to UTC for OPC UA.
|
||||
- LocalizedText (type 15): use empty locale string with the text value.
|
||||
- Every read call at dispatch passes through `Core.Resilience.CapabilityInvoker.InvokeAsync(DriverCapability.Read, …)`.
|
||||
- Returned `DataValueSnapshot` is converted to an OPC UA `DataValue` with `StatusCode`, source timestamp, and server timestamp.
|
||||
- If the owning driver instance's Polly circuit is open, the read returns Bad quality immediately without hitting the wire.
|
||||
- Reads on a node the session has no `Read` bit for in the permission trie return `Bad_UserAccessDenied` before the capability is invoked (OPC-017).
|
||||
- Read timeout is the Polly timeout leg on the `Read` capability; its duration is per-`(DriverInstanceId, HostName)` and comes from the Config DB.
|
||||
|
||||
---
|
||||
|
||||
## OPC-006: Array Support
|
||||
## OPC-006: Write Operations
|
||||
|
||||
Attributes marked as arrays shall have ValueRank=1 and ArrayDimensions set to the attribute's array_dimension value.
|
||||
The server shall fulfill OPC UA `Write` requests by invoking `IWritable.WriteAsync` through `CapabilityInvoker` with **idempotence-aware** retry policy.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- `is_array = 1` produces ValueRank = 1 (OneDimension) and ArrayDimensions = `[array_dimension]`.
|
||||
- `is_array = 0` produces ValueRank = -1 (Scalar) and no ArrayDimensions.
|
||||
- MXAccess reference for array attributes uses `tag_name.attribute[]` (whole array) format.
|
||||
|
||||
### Details
|
||||
|
||||
- Individual array element access (`tag_name.attribute[n]`) is not required for initial implementation. Whole-array read/write only.
|
||||
- If `array_dimension` is null or 0 when `is_array = 1`, log a Warning and default to ArrayDimensions = [0] (variable-length).
|
||||
- Writes dispatch through `CapabilityInvoker.InvokeAsync(DriverCapability.Write, …)`.
|
||||
- Writes **do not auto-retry** unless the tag's `TagConfig.WriteIdempotent = true`, or the driver's capability is marked with `[WriteIdempotent]` (decision #143).
|
||||
- Writes on a node the session lacks the required permission bit for (`WriteOperate`, `WriteTune`, or `WriteConfigure` derived from the tag's `SecurityClassification`) return `Bad_UserAccessDenied` before the capability runs.
|
||||
- A write into an open circuit returns a driver-shaped error (`Bad_NoCommunication` / `Bad_ServerNotConnected`) without hitting the wire.
|
||||
- The server shall coerce the written OPC UA value to the driver's expected native type using the node's `DriverDataType` before calling `WriteAsync`.
|
||||
- Writes to a NodeId not currently in the address space return `Bad_NodeIdUnknown`.
|
||||
|
||||
---
|
||||
|
||||
## OPC-007: Read Operations
|
||||
## OPC-007: Subscriptions and Monitored Items
|
||||
|
||||
The server shall fulfill OPC UA Read requests by reading the corresponding tag value from MXAccess using the tag_name.AttributeName reference.
|
||||
The server shall map OPC UA `CreateMonitoredItems` / `DeleteMonitoredItems` to `ISubscribable.SubscribeAsync` / `UnsubscribeAsync` on the owning driver instance.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- OPC UA Read request for a variable node results in a read via MXAccess using the node's stored `full_tag_reference`.
|
||||
- Returned value is converted from the COM variant to the OPC UA data type specified on the node.
|
||||
- OPC UA StatusCode reflects MXAccess quality: Good maps to Good, Bad/Uncertain map appropriately.
|
||||
- If MXAccess is not connected, return StatusCode = Bad_NotConnected.
|
||||
- Read timeout: configurable, default 5 seconds. On timeout, return Bad_Timeout.
|
||||
|
||||
### Details
|
||||
|
||||
- Prefer cached subscription-delivered values over on-demand reads to reduce COM round-trips.
|
||||
- If no subscription is active for the tag, perform an on-demand read (AddItem, AdviseSupervisory, wait for first OnDataChange, then UnAdvise/RemoveItem).
|
||||
- Concurrency: semaphore-limited to configurable max (default 10) concurrent MXAccess operations.
|
||||
- Subscription setup dispatches through `CapabilityInvoker.InvokeAsync(DriverCapability.Subscribe, …)`.
|
||||
- Two OPC UA monitored items against the same tag produce exactly one driver-side subscription (ref-counted); last unsubscribe releases the driver-side resource.
|
||||
- `OnDataChange` callbacks from the driver arrive as `DataValueSnapshot` and are forwarded to all OPC UA monitored items on that tag.
|
||||
- Driver-side quality maps to OPC UA `StatusCode` per the driver's spec.
|
||||
- When the owning driver's circuit opens, subscribed items publish Bad quality; when it resets, resumption publishes the cached or freshly-sampled value.
|
||||
- Across generation applies that preserve a tag's NodeId, existing OPC UA monitored items are preserved (no re-subscribe required on the client).
|
||||
|
||||
---
|
||||
|
||||
## OPC-008: Write Operations
|
||||
## OPC-008: Alarm Surface
|
||||
|
||||
The server shall fulfill OPC UA Write requests by writing to the corresponding tag via MXAccess.
|
||||
The server shall expose the OPC UA alarm and condition model backed by each driver's `IAlarmSource` (where implemented).
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- OPC UA Write request results in an MXAccess `Write()` call with completion confirmed via `OnWriteComplete()` callback.
|
||||
- Write timeout: configurable, default 5 seconds. On timeout, log Warning and return Bad_Timeout.
|
||||
- MXSTATUS_PROXY with `success = 0` causes the OPC UA write to return Bad_InternalError with the detail message.
|
||||
- MXAccess errors 1008 (no permission), 1012 (secured write), 1013 (verified write) return Bad_UserAccessDenied.
|
||||
- Write to a non-existent tag returns Bad_NodeIdUnknown.
|
||||
- The server shall attempt to convert the written value to the expected Galaxy data type before passing to Write().
|
||||
|
||||
### Details
|
||||
|
||||
- Write uses security classification -1 (no security). Galaxy runtime handles security enforcement.
|
||||
- Write sequence: uses existing subscription handle if available, otherwise AddItem + AdviseSupervisory + Write + await OnWriteComplete + cleanup.
|
||||
- Concurrent write limit: same semaphore as reads (configurable, default 10).
|
||||
- Drivers implementing `IAlarmSource` (today: Galaxy, FOCAS, OPC UA Client) produce alarm events that the core maps onto OPC UA `ConditionType` / `AlarmConditionType` instances in the driver's namespace.
|
||||
- `AlarmSubscribe` dispatches through `CapabilityInvoker.InvokeAsync(DriverCapability.AlarmSubscribe, …)` and retries on transient failure.
|
||||
- `AlarmAcknowledge` from the OPC UA client dispatches through `CapabilityInvoker.InvokeAsync(DriverCapability.AlarmAcknowledge, …)` and **does not retry** (decision #143 — ack is a write-shaped operation).
|
||||
- Alarm-ack requires the `AlarmAck` permission bit for the tag / equipment node; otherwise `Bad_UserAccessDenied`.
|
||||
- Drivers that do not implement `IAlarmSource` contribute no alarm nodes; the core does not synthesize placeholder conditions.
|
||||
|
||||
---
|
||||
|
||||
## OPC-009: Subscriptions
|
||||
## OPC-009: Historical Access
|
||||
|
||||
The server shall support OPC UA subscriptions by mapping them to MXAccess advisory subscriptions and forwarding data change notifications.
|
||||
The server shall surface OPC UA Historical Access (HA) via each driver's `IHistoryProvider` (where implemented).
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- OPC UA CreateMonitoredItems results in MXAccess `AdviseSupervisory()` subscriptions for the requested tags.
|
||||
- Data changes from `OnDataChange` callback are forwarded as OPC UA notifications to all subscribed clients.
|
||||
- Shared subscriptions: if two OPC UA clients subscribe to the same tag, only one MXAccess subscription exists (ref-counted).
|
||||
- Last subscriber unsubscribing triggers UnAdvise/RemoveItem on the MXAccess side.
|
||||
- After MXAccess reconnect, all active MXAccess subscriptions are re-established automatically.
|
||||
|
||||
### Details
|
||||
|
||||
- Publishing interval from the OPC UA subscription request is honored on the OPC UA side; MXAccess delivers changes as fast as it receives them.
|
||||
- OPC UA quality mapping from MXAccess quality integers: 192+ = Good, 64-191 = Uncertain, 0-63 = Bad.
|
||||
- OnDataChange with MXSTATUS_PROXY failure: deliver notification with Bad quality to subscribed clients.
|
||||
- `HistoryRead` for `Raw`, `Processed`, `AtTime`, and `Events` dispatches through `CapabilityInvoker.InvokeAsync(DriverCapability.HistoryRead, …)`.
|
||||
- Drivers implementing `IHistoryProvider` today: Galaxy (Wonderware Historian), OPC UA Client (proxy to remote historian).
|
||||
- Drivers not implementing `IHistoryProvider` return `Bad_HistoryOperationUnsupported` for history requests on their nodes.
|
||||
- History reads require the `Read` permission bit on the target node.
|
||||
|
||||
---
|
||||
|
||||
## OPC-010: Address Space Rebuild
|
||||
## OPC-010: Transport Security Profiles
|
||||
|
||||
When a Galaxy deployment change is detected, the server shall rebuild the address space without dropping existing OPC UA client connections where possible.
|
||||
The server shall offer OPC UA transport-security profiles resolved at startup by `SecurityProfileResolver`.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- When Galaxy Repository detects a deployment change, the OPC UA address space is updated.
|
||||
- Only changed gobject subtrees are torn down and rebuilt; unchanged nodes, subscriptions, and alarm tracking remain intact.
|
||||
- Existing OPC UA client sessions are preserved — clients stay connected.
|
||||
- Subscriptions for tags on unchanged objects continue to work without interruption.
|
||||
- Subscriptions for tags that no longer exist receive a Bad_NodeIdUnknown status notification.
|
||||
- Sync is logged at Information level with the number of changed gobjects.
|
||||
|
||||
### Details
|
||||
|
||||
- Uses incremental subtree sync: compares previous hierarchy+attributes with new, identifies changed gobject IDs, expands to include child subtrees, tears down only affected subtrees, and rebuilds them.
|
||||
- First build (no cached state) performs a full build.
|
||||
- If no changes are detected, the sync is a no-op (logged and skipped).
|
||||
- Alarm tracking and MXAccess subscriptions for unchanged objects are not disrupted.
|
||||
- Falls back to full rebuild behavior if the entire hierarchy changes.
|
||||
- Supported profiles: `None`, `Basic256Sha256-Sign`, `Basic256Sha256-SignAndEncrypt`, `Aes128_Sha256_RsaOaep-Sign`, `Aes128_Sha256_RsaOaep-SignAndEncrypt`, `Aes256_Sha256_RsaPss-Sign`, `Aes256_Sha256_RsaPss-SignAndEncrypt`.
|
||||
- Active profile list comes from `OpcUa.SecurityProfile` in `appsettings.json` (bootstrap config) or Config DB (per-cluster override).
|
||||
- Server certificate is created at first startup even when only `None` is enabled, because UserName-token encryption depends on an ApplicationInstanceCertificate.
|
||||
- Certificate store root path is configurable (default `%ProgramData%/OtOpcUa/pki/`).
|
||||
- `AutoAcceptUntrustedClientCertificates` is a config flag; production deployments set it to `false` and operators add trusted client certs via the Admin UI Cert Trust screen.
|
||||
|
||||
---
|
||||
|
||||
## OPC-011: Server Diagnostics Node
|
||||
## OPC-011: UserName Authentication
|
||||
|
||||
The server shall expose a ServerStatus node under the standard OPC UA Server object with ServerState, CurrentTime, and StartTime. This is required by the OPC UA specification for compliant servers.
|
||||
The server shall validate `UserNameIdentityToken` credentials against LDAP (production: Active Directory; dev: GLAuth).
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- ServerState reports Running during normal operation.
|
||||
- CurrentTime returns the server's current UTC time.
|
||||
- StartTime returns the UTC time when the service started.
|
||||
- If `Ldap.Enabled = false`, all UserName tokens are rejected (`BadUserAccessDenied`).
|
||||
- When enabled, the server performs an LDAP bind using the supplied credentials via `LdapUserAuthenticator`.
|
||||
- On successful bind, group memberships resolved from LDAP are mapped through `LdapOptions.GroupToRole` to produce the session's permission bits (`ReadOnly`, `WriteOperate`, `WriteTune`, `WriteConfigure`, `AlarmAck`).
|
||||
- `LdapAuthenticationProvider` implements both `IUserAuthenticationProvider` and `IRoleProvider`.
|
||||
- UserName tokens are always carried on an encrypted secure channel (either Sign-and-Encrypt transport, or encrypted token using the server certificate even on a `None` channel).
|
||||
|
||||
---
|
||||
|
||||
## OPC-012: Namespace Configuration
|
||||
## OPC-012: Capability Dispatch via CapabilityInvoker
|
||||
|
||||
The server shall register a namespace URI at namespace index 1. All application-specific NodeIds shall use this namespace.
|
||||
Every async capability-interface call the server makes shall route through `Core.Resilience.CapabilityInvoker`.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Namespace URI: `urn:ZB:LmxOpcUa` (Galaxy name is configurable).
|
||||
- All object and variable NodeIds created from Galaxy data use namespace index 1.
|
||||
- Standard OPC UA nodes remain in namespace 0.
|
||||
- `CapabilityInvoker.InvokeAsync` resolves a Polly resilience pipeline keyed on `(DriverInstanceId, HostName, DriverCapability)`.
|
||||
- Read / Discover / Probe / Subscribe / AlarmSubscribe / HistoryRead pipelines carry Timeout + Retry + CircuitBreaker strategies.
|
||||
- Write / AlarmAcknowledge pipelines carry Timeout + CircuitBreaker only; Retry is enabled only when the tag or capability carries `[WriteIdempotent]` (decision #143).
|
||||
- Roslyn diagnostic **OTOPCUA0001** fires on any direct call to a capability-interface method from outside `CapabilityInvoker` (enforced via `ZB.MOM.WW.OtOpcUa.Analyzers`).
|
||||
|
||||
---
|
||||
|
||||
## OPC-013: Session Management
|
||||
## OPC-013: Per-Host Polly Isolation
|
||||
|
||||
Polly pipelines shall be keyed per `(DriverInstanceId, HostName, DriverCapability)` so that a failing device in one driver does not trip the circuit for another device on the same driver or any other driver (decision #144).
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- A driver serving `N` devices has `N × capabilityCount` distinct pipelines.
|
||||
- Circuit-breaker state transitions are telemetry-published per pipeline and appear on the Admin UI + `/metrics`.
|
||||
- A host-scope fault (e.g. shared PLC gateway) naturally trips all devices behind that host but leaves other hosts untouched.
|
||||
|
||||
---
|
||||
|
||||
## OPC-014: Authorization Gate and Permission Trie
|
||||
|
||||
`Security.AuthorizationGate` shall enforce node-level permissions on every browse, read, write, subscribe, alarm-ack, and history call before dispatch.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Permission bits for the session are assembled at login from LDAP group → role → permission mapping plus Config-DB `NodeAcl` rows that modify permission inheritance along the browse tree.
|
||||
- The permission trie walks from the addressed node toward the root, inheriting permissions unless a `NodeAcl` overrides; first match wins.
|
||||
- Missing `Read` bit → `Bad_UserAccessDenied` on Read / Subscribe / HistoryRead.
|
||||
- Missing `Write*` bit (matching the tag's `SecurityClassification`) → `Bad_UserAccessDenied` on Write.
|
||||
- Missing `AlarmAck` bit → `Bad_UserAccessDenied` on acknowledge.
|
||||
- Authorization decisions are made at the server layer only — drivers never enforce authorization and only expose `SecurityClassification` metadata.
|
||||
|
||||
---
|
||||
|
||||
## OPC-015: ServiceLevel Reporting
|
||||
|
||||
The server shall expose a dynamic `ServiceLevel` value computed by `RedundancyCoordinator` + `ServiceLevelCalculator`.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- `ServiceLevel` reflects: redundancy role (Primary higher than Secondary), publish state (current generation applied > mid-apply > failed-apply), driver health (any driver instance in open circuit lowers the value), apply-lease state.
|
||||
- `ServiceLevel` is exposed as a Variable under the standard `Server` object and is readable by any authenticated client.
|
||||
- Clients that observe Primary's `ServiceLevel` drop below Secondary's should failover per the OPC UA spec.
|
||||
- Single-node deployments (`NodeCount = 1`) always publish their node as Primary.
|
||||
|
||||
---
|
||||
|
||||
## OPC-016: Session Management
|
||||
|
||||
The server shall support multiple concurrent OPC UA client sessions.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Maximum concurrent sessions: configurable, default 100.
|
||||
- Session timeout: configurable, default 30 minutes of inactivity.
|
||||
- Expired sessions are cleaned up and their subscriptions removed.
|
||||
- Session count is reported to the status dashboard.
|
||||
- Maximum concurrent sessions and session timeout come from Config DB cluster settings (default 100 sessions, 30-minute idle timeout).
|
||||
- Expired sessions are cleaned up and their subscriptions and monitored items removed.
|
||||
- Active session count is reported as a Prometheus gauge on the Admin `/metrics` endpoint.
|
||||
|
||||
---
|
||||
|
||||
## OPC-017: Address Space Rebuild on Generation Apply
|
||||
|
||||
When a new Config DB generation is applied, the server shall surgically update only the affected driver subtrees.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Apply compares the previous generation to the incoming generation and produces per-driver add / modify / remove sets.
|
||||
- Existing OPC UA sessions, subscriptions, and monitored items are preserved across apply whenever the target NodeId survives the generation change.
|
||||
- Tags that no longer exist post-apply emit `Bad_NodeIdUnknown` on their subscribed monitored items.
|
||||
- During apply, the node's `ServiceLevel` is lowered (per `ServiceLevelCalculator`) so redundancy partners temporarily take precedence.
|
||||
- Galaxy subtree rebuilds triggered by `IRediscoverable` (Galaxy deployment change) are scoped to the Galaxy driver's namespace and follow the same preservation rule (OPC-006 from the v1 file, now subsumed).
|
||||
|
||||
---
|
||||
|
||||
## OPC-018: Server Diagnostics Nodes
|
||||
|
||||
The server shall expose standard OPC UA `Server` object nodes required by the spec.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- `ServerStatus` / `ServerState` / `CurrentTime` / `StartTime` populated and compliant with the OPC UA 1.05 spec.
|
||||
- `ServerCapabilities` declares historical access capabilities for namespaces that have an `IHistoryProvider`-backed driver.
|
||||
- `ServerRedundancy.RedundancySupport` reflects the cluster's redundancy mode (`None` / `Warm` / `Hot`).
|
||||
- `ServerRedundancy.ServerUriArray` lists both cluster members' `ApplicationUri` values.
|
||||
|
||||
---
|
||||
|
||||
## OPC-019: Observability Hooks
|
||||
|
||||
The server shall emit OpenTelemetry metrics consumed by the Admin `/metrics` Prometheus endpoint.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Counters: capability calls per `DriverInstanceId` + `DriverCapability`, OPC UA requests per method, alarm events emitted, history reads, generation apply attempts.
|
||||
- Histograms: capability-call duration per `DriverInstanceId` + `DriverCapability`, OPC UA request duration per method.
|
||||
- Gauges: circuit-breaker state per pipeline, active OPC UA sessions, active monitored items, subscription queue depth, `ServiceLevel` value, memory-tracking watermarks (Phase 6.1).
|
||||
- Metric cardinality is bounded — `DriverInstanceId` and `HostName` are the only high-cardinality labels, both controlled by the Config DB.
|
||||
|
||||
@@ -1,117 +1,265 @@
|
||||
# Service Host — Component Requirements
|
||||
|
||||
Parent: [HLR-006](HighLevelReqs.md#hlr-006-windows-service-hosting), [HLR-007](HighLevelReqs.md#hlr-007-logging)
|
||||
> **Revision** — Refreshed 2026-04-19 for the OtOpcUa v2 multi-driver platform (task #205). v1 was a single Windows service; v2 ships **three cooperating Windows services** and the service-host requirements are rewritten per-process. SVC-001…SVC-006 from v1 are preserved in spirit (TopShelf, Serilog, config loading, graceful shutdown, startup sequence, unhandled-exception handling) but are now scoped to the process they apply to. SRV-* prefixes the Server process, ADM-* the Admin process, GHX-* the Galaxy Host process. A shared-requirements section at the top covers cross-process concerns (Serilog, logging rotation, bootstrap config scope).
|
||||
|
||||
## SVC-001: TopShelf Hosting
|
||||
Parent: [HLR-007](HighLevelReqs.md#hlr-007-service-hosting), [HLR-008](HighLevelReqs.md#hlr-008-logging), [HLR-011](HighLevelReqs.md#hlr-011-config-db-and-draft-publish)
|
||||
|
||||
The application shall use TopShelf for Windows service lifecycle (install, uninstall, start, stop) and interactive console mode for development.
|
||||
## Shared Requirements (all three processes)
|
||||
|
||||
### Acceptance Criteria
|
||||
### SVC-SHARED-001: Serilog Logging
|
||||
|
||||
- TopShelf HostFactory configures the service with name `LmxOpcUa`, display name `LMX OPC UA Server`.
|
||||
- Service installs via command line: `ZB.MOM.WW.OtOpcUa.Host.exe install`.
|
||||
- Service uninstalls via: `ZB.MOM.WW.OtOpcUa.Host.exe uninstall`.
|
||||
- Service runs as LocalSystem account (needed for MXAccess COM access and Windows Auth to SQL Server).
|
||||
- Interactive console mode (exe with no args) works for development/debugging.
|
||||
- `StartAutomatically` is set for Windows service registration.
|
||||
Every process shall use Serilog with a rolling daily file sink at Information level minimum, plus a console sink, plus opt-in CompactJsonFormatter file sink.
|
||||
|
||||
### Details
|
||||
#### Acceptance Criteria
|
||||
|
||||
- Platform target: x86 (32-bit) — required for MXAccess COM interop.
|
||||
- Service description: "OPC UA server exposing System Platform Galaxy tags via MXAccess."
|
||||
- Console sink active on every process (for interactive / debug mode).
|
||||
- Rolling daily file sink:
|
||||
- Server: `logs/otopcua-YYYYMMDD.log`
|
||||
- Admin: `logs/otopcua-admin-YYYYMMDD.log`
|
||||
- Galaxy Host: `%ProgramData%\OtOpcUa\galaxy-host-YYYYMMDD.log`
|
||||
- Retention count and min level configurable via `Serilog:*` in each process's `appsettings.json`.
|
||||
- JSON sink opt-in via `Serilog:WriteJson = true` (emits `*.json.log` alongside the plain-text file) for SIEM ingestion.
|
||||
- `Log.CloseAndFlush()` invoked in a `finally` block on shutdown.
|
||||
- Structured logging (Serilog message templates) — no `string.Format`.
|
||||
|
||||
---
|
||||
|
||||
## SVC-002: Serilog Logging
|
||||
### SVC-SHARED-002: Bootstrap Configuration Scope
|
||||
|
||||
The application shall configure Serilog with a rolling daily file sink and console sink, with log files retained for a configurable number of days (default 31).
|
||||
`appsettings.json` is bootstrap-only per HLR-011. Operational configuration (clusters, drivers, namespaces, tags, ACLs, poll groups) lives in the Config DB.
|
||||
|
||||
### Acceptance Criteria
|
||||
#### Acceptance Criteria
|
||||
|
||||
- Console sink active (for interactive/debug mode).
|
||||
- Rolling daily file sink writing to `logs/lmxopcua-YYYYMMDD.log`.
|
||||
- Retained file count: configurable, default 31 days.
|
||||
- Minimum log level: configurable, default Information.
|
||||
- Log file path: configurable, default `logs/lmxopcua-.log`.
|
||||
- Serilog is initialized before any other component (first thing in Main).
|
||||
- `Log.CloseAndFlush()` called in finally block on exit.
|
||||
|
||||
### Details
|
||||
|
||||
- Structured logging with Serilog message templates (not string.Format).
|
||||
- Log output includes timestamp, level, source context, message, and exception.
|
||||
- Fatal exceptions are caught at the top level and logged before exit.
|
||||
- `appsettings.json` may contain only: Config DB connection string, `Node:NodeId`, `Node:ClusterId`, `Node:LocalCachePath`, `OpcUa:*` security bootstrap fields, `Ldap:*` bootstrap fields, `Serilog:*`, `Redundancy:*` role id.
|
||||
- Any attempt to configure driver instances, tags, or equipment through `appsettings.json` shall be rejected at startup with a descriptive error.
|
||||
- Invalid or missing required bootstrap fields are detected at startup with a clear error (`"Node:NodeId not configured"` style).
|
||||
|
||||
---
|
||||
|
||||
## SVC-003: Configuration
|
||||
## OtOpcUa.Server — Service Host Requirements (SRV-*)
|
||||
|
||||
The application shall load configuration from appsettings.json with support for environment-specific overrides (appsettings.*.json) and environment variables.
|
||||
### SRV-001: Microsoft.Extensions.Hosting + AddWindowsService
|
||||
|
||||
### Acceptance Criteria
|
||||
The Server shall use `Host.CreateApplicationBuilder(args)` with `AddWindowsService(o => o.ServiceName = "OtOpcUa")` to run as a Windows service.
|
||||
|
||||
- `appsettings.json` is the primary configuration file.
|
||||
- Environment-specific overrides via `appsettings.{environment}.json`.
|
||||
- Configuration sections: `OpcUa`, `MxAccess`, `GalaxyRepository`, `Dashboard`.
|
||||
- Missing optional configuration keys use documented defaults (service does not crash).
|
||||
- Invalid configuration (e.g., port = -1) is detected at startup with a clear error message.
|
||||
#### Acceptance Criteria
|
||||
|
||||
### Details
|
||||
|
||||
- Config is loaded once at startup. No hot-reload (service restart required for config changes). This is appropriate for an industrial service.
|
||||
- All configurable values and their defaults are documented in `appsettings.json`.
|
||||
- Service name `OtOpcUa`.
|
||||
- Installs via standard `sc.exe` tooling or the build-provided installer.
|
||||
- Runs as a configured service account (typically a domain service account with Config DB read access; Windows Auth to SQL Server).
|
||||
- Console mode (running `ZB.MOM.WW.OtOpcUa.Server.exe` with no Windows service context) works for development and debugging.
|
||||
- Platform target: .NET 10 x64 (default per decision in `plan.md` §3).
|
||||
|
||||
---
|
||||
|
||||
## SVC-004: Graceful Shutdown
|
||||
### SRV-002: Startup Sequence
|
||||
|
||||
On service stop, the application shall gracefully shut down all components and flush logs before exiting.
|
||||
The Server shall start components in a defined order, with failure handling at each step.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- TopShelf WhenStopped triggers orderly shutdown.
|
||||
- Shutdown sequence: (1) stop change detection polling, (2) stop OPC UA server (stop accepting new sessions, complete pending operations), (3) disconnect MXAccess (cleanup all COM objects), (4) stop status dashboard HTTP listener, (5) flush Serilog.
|
||||
- Shutdown completes within 30 seconds (Windows SCM timeout).
|
||||
- All IDisposable components are disposed in reverse-creation order.
|
||||
|
||||
### Details
|
||||
|
||||
- `CancellationTokenSource` signals all background loops (monitor, change detection, HTTP listener) to stop.
|
||||
- Log "Service shutdown complete" at Information level as the final log entry before flush.
|
||||
|
||||
---
|
||||
|
||||
## SVC-005: Startup Sequence
|
||||
|
||||
The service shall start components in a defined order, with failure handling at each step.
|
||||
|
||||
### Acceptance Criteria
|
||||
#### Acceptance Criteria
|
||||
|
||||
- Startup sequence:
|
||||
1. Load configuration
|
||||
2. Initialize Serilog
|
||||
3. Start STA thread
|
||||
4. Connect to MXAccess
|
||||
5. Query Galaxy Repository for initial build
|
||||
6. Build OPC UA address space
|
||||
7. Start OPC UA server listener
|
||||
8. Start change detection polling
|
||||
9. Start status dashboard HTTP listener
|
||||
- Failure in steps 1-4 prevents startup (service fails to start).
|
||||
- Failure in steps 5-9 logs Error but allows the service to run in degraded mode.
|
||||
|
||||
### Details
|
||||
|
||||
- Degraded mode means the service is running but may have an empty address space (waiting for Galaxy DB) or no dashboard (port conflict). MXAccess connection is the minimum required for the service to be useful.
|
||||
1. Load `appsettings.json` bootstrap configuration + initialize Serilog.
|
||||
2. Validate bootstrap fields (NodeId, ClusterId, Config DB connection).
|
||||
3. Initialize `OpcUaApplicationHost` (server-certificate resolution via `SecurityProfileResolver`).
|
||||
4. Connect to Config DB; request current published generation for `ClusterId`.
|
||||
5. If unreachable, fall back to `LiteDbConfigCache` (latest applied generation).
|
||||
6. Apply generation: register driver instances, build namespaces, wire capability pipelines.
|
||||
7. Start `OpcUaServerService` hosted service (opens endpoint listener).
|
||||
8. Start `HostStatusPublisher` (pushes `ClusterNodeGenerationState` to Config DB for Admin UI SignalR consumers).
|
||||
9. Start `RedundancyCoordinator` + `ServiceLevelCalculator`.
|
||||
- Failure in steps 1-3 prevents startup.
|
||||
- Failure in steps 4-6 logs Error and enters degraded mode (empty namespaces, `DriverHealth.Unavailable` on every driver, `ServiceLevel = 0`).
|
||||
- Failure in steps 7-9 logs Error and shuts down (endpoint is non-optional).
|
||||
|
||||
---
|
||||
|
||||
## SVC-006: Unhandled Exception Handling
|
||||
### SRV-003: Graceful Shutdown
|
||||
|
||||
The service shall handle unexpected crashes gracefully.
|
||||
On service stop, the Server shall gracefully shut down all driver instances, the OPC UA listener, and flush logs before exiting.
|
||||
|
||||
### Acceptance Criteria
|
||||
#### Acceptance Criteria
|
||||
|
||||
- Register `AppDomain.CurrentDomain.UnhandledException` handler that logs Fatal before the process terminates.
|
||||
- TopShelf service recovery is configured: restart on failure with 60-second delay.
|
||||
- Fatal-level log entry includes the full exception details.
|
||||
- `IHostApplicationLifetime.ApplicationStopping` triggers orderly shutdown.
|
||||
- Shutdown sequence: stop `HostStatusPublisher` → stop driver instances (disconnect each via `IDriver.DisposeAsync`, which for Galaxy tears down the named pipe) → stop OPC UA server (stop accepting new sessions, complete pending reads/writes) → flush Serilog.
|
||||
- Shutdown completes within 30 seconds (Windows SCM timeout).
|
||||
- All `IDisposable` / `IAsyncDisposable` components disposed in reverse-creation order.
|
||||
- Final log entry: `"OtOpcUa.Server shutdown complete"` at Information level.
|
||||
|
||||
---
|
||||
|
||||
### SRV-004: Unhandled Exception Handling
|
||||
|
||||
The Server shall handle unexpected crashes gracefully.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- Registers `AppDomain.CurrentDomain.UnhandledException` handler that logs Fatal before the process terminates.
|
||||
- Windows service recovery configured: restart on failure with 60-second delay.
|
||||
- Fatal log entry includes full exception details.
|
||||
|
||||
---
|
||||
|
||||
### SRV-005: Drivers Hosted In-Process
|
||||
|
||||
All drivers except Galaxy run in-process within the Server.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- Modbus TCP, AB CIP, AB Legacy, S7, TwinCAT, FOCAS, OPC UA Client drivers are resolved from the DI container and managed by `DriverHost`.
|
||||
- Galaxy driver in-process component is `Driver.Galaxy.Proxy`, which forwards to `OtOpcUa.Galaxy.Host` over the named pipe (see GHX-*).
|
||||
- Each driver instance's lifecycle (connect, discover, subscribe, dispose) is orchestrated by `DriverHost`.
|
||||
|
||||
---
|
||||
|
||||
### SRV-006: Redundancy-Node Bootstrap
|
||||
|
||||
The Server shall bootstrap its redundancy identity from `appsettings.json` and the Config DB.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- `Node:NodeId` + `Node:ClusterId` identify this node uniquely; the `Redundancy` coordinator looks up `ClusterNode.RedundancyRole` (Primary / Secondary) from the Config DB.
|
||||
- Two nodes of the same cluster connect to the same Config DB and the same ClusterId but have different NodeIds and different `ApplicationUri` values.
|
||||
- Missing or ambiguous `(ClusterId, NodeId)` causes startup failure.
|
||||
|
||||
---
|
||||
|
||||
## OtOpcUa.Admin — Service Host Requirements (ADM-*)
|
||||
|
||||
### ADM-001: ASP.NET Core Blazor Server
|
||||
|
||||
The Admin app shall use `WebApplication.CreateBuilder` with Razor Components (`AddRazorComponents().AddInteractiveServerComponents()`), SignalR, and cookie authentication.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- Blazor Server (not WebAssembly) per `plan.md` §Tech Stack.
|
||||
- Hosts SignalR hubs for live cluster state (used by `ClusterNodeGenerationState` views, crash-loop alerts, etc.).
|
||||
- Runs as a Windows service via `AddWindowsService` OR as a standard ASP.NET Core process behind IIS / reverse proxy (site decides).
|
||||
- Platform target: .NET 10 x64.
|
||||
|
||||
---
|
||||
|
||||
### ADM-002: Authentication and Authorization
|
||||
|
||||
Admin users authenticate via LDAP bind with cookie auth; three admin roles gate operations.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- Cookie auth scheme: `OtOpcUa.Admin`, 8-hour expiry, path `/login` for challenge.
|
||||
- LDAP bind via `LdapAuthService`; user group memberships map to admin roles (`ConfigViewer`, `ConfigEditor`, `FleetAdmin`).
|
||||
- Authorization policies:
|
||||
- `CanEdit` requires `ConfigEditor` or `FleetAdmin`.
|
||||
- `CanPublish` requires `FleetAdmin`.
|
||||
- View-only access requires `ConfigViewer` (or higher).
|
||||
- Unauthenticated requests to any Admin page redirect to `/login`.
|
||||
- Per-cluster role grants layer on top: a `ConfigEditor` with no grant for cluster X can view it but not edit.
|
||||
|
||||
---
|
||||
|
||||
### ADM-003: Config DB as Sole Write Path
|
||||
|
||||
The Admin service shall be the only process with write access to the Config DB.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- EF Core `OtOpcUaConfigDbContext` configured with the SQL login / connection string that has read+write permission on config tables.
|
||||
- Server nodes connect with a read-only principal (`grant SELECT` only).
|
||||
- Admin writes produce draft-generation rows; publish writes are atomic and transactional.
|
||||
- Every write is audited via `AuditLogService` per ADM-006.
|
||||
|
||||
---
|
||||
|
||||
### ADM-004: Prometheus /metrics Endpoint
|
||||
|
||||
The Admin service shall expose an OpenTelemetry → Prometheus metrics endpoint at `/metrics`.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- `OpenTelemetry.Metrics` registered with Prometheus exporter.
|
||||
- `/metrics` scrapeable without authentication (standard Prometheus pattern) OR gated behind an infrastructure allow-list (site-configurable).
|
||||
- Exports metrics from Server nodes of managed clusters (aggregated via Config DB heartbeat telemetry) plus Admin-local metrics (login attempts, publish duration, active sessions).
|
||||
|
||||
---
|
||||
|
||||
### ADM-005: Graceful Shutdown
|
||||
|
||||
On shutdown, the Admin service shall disconnect SignalR clients cleanly, finish in-flight DB writes, and flush Serilog.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- `IHostApplicationLifetime.ApplicationStopping` closes SignalR hub connections gracefully.
|
||||
- In-flight publish transactions are allowed to complete up to 30 seconds.
|
||||
- Final log entry: `"OtOpcUa.Admin shutdown complete"`.
|
||||
|
||||
---
|
||||
|
||||
### ADM-006: Audit Logging
|
||||
|
||||
Every publish and every ACL / role-grant change shall produce an immutable audit row via `AuditLogService`.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- Audit rows include: timestamp (UTC), acting principal (LDAP DN + display name), action, entity kind + id, before/after generation number where applicable, session id, source IP.
|
||||
- Audit rows are never mutated or deleted by application code.
|
||||
- Audit table schema enforces immutability via DB permissions (no UPDATE / DELETE granted to the Admin app's principal).
|
||||
|
||||
---
|
||||
|
||||
## OtOpcUa.Galaxy.Host — Service Host Requirements (GHX-*)
|
||||
|
||||
### GHX-001: TopShelf Windows Service Hosting
|
||||
|
||||
The Galaxy Host shall use TopShelf for Windows service lifecycle (install, uninstall, start, stop) and interactive console mode.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- Service name `OtOpcUaGalaxyHost`, display name `OtOpcUa Galaxy Host`.
|
||||
- Installs via `ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.exe install`.
|
||||
- Uninstalls via `ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.exe uninstall`.
|
||||
- Runs as a configured user account (typically the same account as the Server, or a dedicated Galaxy service account with ArchestrA platform access).
|
||||
- Interactive console mode (no args) for development / debugging.
|
||||
- Platform target: **.NET Framework 4.8 x86** — required for MXAccess COM 32-bit interop.
|
||||
- Development deployments may use NSSM in place of TopShelf (memory: `project_galaxy_host_installed`).
|
||||
|
||||
### Details
|
||||
|
||||
- Service description: "OtOpcUa Galaxy Host — MXAccess + Galaxy Repository backend for the Galaxy driver, named-pipe IPC to OtOpcUa.Server."
|
||||
|
||||
---
|
||||
|
||||
### GHX-002: Named-Pipe IPC Bootstrap
|
||||
|
||||
The Host shall open a named pipe on startup whose name, ACL, and shared secret come from environment variables supplied by the supervisor at spawn time.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- `OTOPCUA_GALAXY_PIPE` → pipe name (default `OtOpcUaGalaxy`).
|
||||
- `OTOPCUA_ALLOWED_SID` → SID of the principal allowed to connect; any other principal is denied at the ACL layer.
|
||||
- `OTOPCUA_GALAXY_SECRET` → per-process shared secret; `Driver.Galaxy.Proxy` must present it on handshake.
|
||||
- `OTOPCUA_GALAXY_BACKEND` → `stub` / `db` / `mxaccess` (default `mxaccess`) — selects which backend implementation is loaded.
|
||||
- Missing `OTOPCUA_ALLOWED_SID` or `OTOPCUA_GALAXY_SECRET` at startup throws with a descriptive error.
|
||||
|
||||
---
|
||||
|
||||
### GHX-003: Backend Lifecycle
|
||||
|
||||
The Host shall instantiate the STA pump + MXAccess backend + Galaxy Repository + optional Historian plugin in a defined order and tear them down cleanly on shutdown.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- Startup (mxaccess backend): initialize Serilog → resolve env vars → create `PipeServer` → start `StaPump` → create `MxAccessClient` on STA thread → initialize `GalaxyRepository` → optionally initialize Historian plugin → begin pipe request handling.
|
||||
- Shutdown: stop pipe → dispose MxAccessClient (MXA-007 COM cleanup) → dispose STA pump → flush Serilog.
|
||||
- Shutdown must complete within 30 seconds (Windows SCM timeout).
|
||||
- `Console.CancelKeyPress` triggers the same sequence in console mode.
|
||||
|
||||
---
|
||||
|
||||
### GHX-004: Unhandled Exception Handling
|
||||
|
||||
The Host shall log Fatal on crash and let the supervisor restart it.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
- `AppDomain.CurrentDomain.UnhandledException` handler logs Fatal with full exception details before termination.
|
||||
- The supervisor's driver-stability policy (`docs/v2/driver-stability.md`) governs restart behavior — backoff, crash-loop detection, and alerting live there, not in the Host.
|
||||
- Server-side: `Driver.Galaxy.Proxy` detects pipe disconnect, opens its capability circuit, reports Bad quality on Galaxy nodes; reconnects automatically when the Host is back.
|
||||
|
||||
@@ -1,157 +1,29 @@
|
||||
# Status Dashboard — Component Requirements
|
||||
# Status Dashboard — Retired
|
||||
|
||||
Parent: [HLR-009](HighLevelReqs.md#hlr-009-status-dashboard)
|
||||
> **Revision** — Retired 2026-04-19 (task #205). The embedded HTTP Status Dashboard hosted inside the v1 LmxOpcUa service (`Dashboard:Port 8081`) has been **superseded by the Admin UI** introduced in OtOpcUa v2. The requirements formerly numbered DASH-001 through DASH-009 no longer apply.
|
||||
|
||||
Reference: LmxProxy Status Dashboard (see `dashboard.JPG` in project root).
|
||||
## What replaces it
|
||||
|
||||
## DASH-001: Embedded HTTP Endpoint
|
||||
Operator surface is now the **OtOpcUa Admin** Blazor Server web app:
|
||||
|
||||
The service shall host a lightweight HTTP listener on a configurable port serving a self-contained HTML status dashboard page (no external dependencies).
|
||||
- Canonical design doc: `docs/v2/admin-ui.md`
|
||||
- High-level operator surface requirement: [HLR-015](HighLevelReqs.md#hlr-015-admin-ui-operator-surface)
|
||||
- Service-host requirements for the Admin process: [ServiceHostReqs.md → ADM-*](ServiceHostReqs.md#otopcua-admin---service-host-requirements-adm-)
|
||||
- Cross-cluster metrics endpoint: `/metrics` on the Admin app — see [HLR-017](HighLevelReqs.md#hlr-017-prometheus-metrics).
|
||||
- Audit log: see [HLR-016](HighLevelReqs.md#hlr-016-audit-logging) and `AuditLogService`.
|
||||
|
||||
### Acceptance Criteria
|
||||
## Mapping from retired DASH-* requirements to today's surface
|
||||
|
||||
- Uses `System.Net.HttpListener` on a configurable port (`Dashboard:Port`, default 8081).
|
||||
- Routes:
|
||||
- `GET /` → HTML dashboard
|
||||
- `GET /api/status` → JSON status report
|
||||
- `GET /api/health` → 200 OK if healthy, 503 if unhealthy
|
||||
- Only GET requests accepted; other methods return 405.
|
||||
- Unknown paths return 404.
|
||||
- All responses include `Cache-Control: no-cache, no-store, must-revalidate` headers.
|
||||
- Dashboard can be disabled via config (`Dashboard:Enabled`, default true).
|
||||
| Retired requirement | Replacement |
|
||||
|---------------------|-------------|
|
||||
| DASH-001 Embedded HTTP listener | Admin UI (Blazor Server) hosted in the `OtOpcUa.Admin` process. |
|
||||
| DASH-002 Connection panel | Admin UI cluster-node view (live via SignalR) shows per-driver connection state. |
|
||||
| DASH-003 Health panel | Admin UI renders `DriverHealth` + Polly circuit state per driver instance; cluster-level rollup on the cluster dashboard. |
|
||||
| DASH-004 Subscriptions panel | Prometheus gauges (session count, monitored-item count, driver-subscription count) exposed via `/metrics`. |
|
||||
| DASH-005 Operations table | Capability-call duration histograms + counts exposed via `/metrics`; Admin UI renders latency summaries per `DriverInstanceId`. |
|
||||
| DASH-006 Footer (last-updated + version) | Admin UI footer; version stamped from the assembly version of the Admin app. |
|
||||
| DASH-007 Auto-refresh | Admin UI uses SignalR push for live updates — no meta-refresh. |
|
||||
| DASH-008 JSON status API | Prometheus `/metrics` endpoint is the programmatic surface. |
|
||||
| DASH-009 Galaxy info panel | Admin UI Galaxy-driver-instance detail view (driver config, last discovery time, Galaxy DB connection state, MXAccess pipe health). |
|
||||
|
||||
### Details
|
||||
|
||||
- HTTP prefix: `http://+:{port}/` to bind to all interfaces.
|
||||
- If HttpListener fails to start (port conflict, missing URL reservation), log Error and continue service startup without the dashboard.
|
||||
- HTML page is self-contained: inline CSS, no external resources (no CDN, no JavaScript frameworks).
|
||||
|
||||
---
|
||||
|
||||
## DASH-002: Connection Panel
|
||||
|
||||
The dashboard shall display a Connection panel showing MXAccess connection state.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Shows: **Connected** (True/False), **State** (Connected/Disconnected/Reconnecting/Error), **Connected Since** (UTC timestamp).
|
||||
- Green left border when Connected, red when Disconnected/Error, yellow when Reconnecting.
|
||||
- "Connected Since" shows "N/A" when not connected.
|
||||
- Data sourced from MXAccess client's connection state properties.
|
||||
|
||||
### Details
|
||||
|
||||
- Timestamp format: `yyyy-MM-dd HH:mm:ss UTC`.
|
||||
- Panel title: "Connection".
|
||||
|
||||
---
|
||||
|
||||
## DASH-003: Health Panel
|
||||
|
||||
The dashboard shall display a Health panel showing overall service health.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Three states: **Healthy** (green text), **Degraded** (yellow text), **Unhealthy** (red text).
|
||||
- Includes a health message string explaining the status.
|
||||
- Health rules:
|
||||
- Not connected to MXAccess → Unhealthy
|
||||
- Success rate < 50% with > 100 total operations → Degraded
|
||||
- Connected with acceptable success rate → Healthy
|
||||
|
||||
### Details
|
||||
|
||||
- Health message examples: "LmxOpcUa is healthy", "MXAccess client is not connected", "Average success rate is below 50%".
|
||||
- Green left border for Healthy, yellow for Degraded, red for Unhealthy.
|
||||
|
||||
---
|
||||
|
||||
## DASH-004: Subscriptions Panel
|
||||
|
||||
The dashboard shall display a Subscriptions panel showing subscription statistics.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Shows: **Clients** (connected OPC UA client count), **Tags** (total variable nodes in address space), **Active** (active MXAccess subscriptions), **Delivered** (cumulative data change notifications delivered).
|
||||
- Values update on each dashboard refresh.
|
||||
- Zero values shown as "0", not blank.
|
||||
|
||||
### Details
|
||||
|
||||
- "Tags" is the count of variable nodes, not object/folder nodes.
|
||||
- "Active" is the count of distinct MXAccess item subscriptions (after ref-counting — the number of actual AdviseSupervisory calls, not the number of OPC UA monitored items).
|
||||
- "Delivered" is a running counter since service start (not reset on reconnect).
|
||||
|
||||
---
|
||||
|
||||
## DASH-005: Operations Table
|
||||
|
||||
The dashboard shall display an operations metrics table showing performance statistics.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Table with columns: **Operation**, **Count**, **Success Rate**, **Avg (ms)**, **Min (ms)**, **Max (ms)**, **P95 (ms)**.
|
||||
- Rows: Read, Write, Subscribe, Browse.
|
||||
- Empty cells show em-dash ("—") when no data available (count = 0).
|
||||
- Success rate displayed as percentage (e.g., "99.8%").
|
||||
- Latency values rounded to 1 decimal place.
|
||||
|
||||
### Details
|
||||
|
||||
- Metrics sourced from the PerformanceMetrics component (1000-entry rolling buffer for percentile calculation).
|
||||
- "Browse" row tracks OPC UA browse operations.
|
||||
- "Subscribe" row tracks OPC UA CreateMonitoredItems operations.
|
||||
|
||||
---
|
||||
|
||||
## DASH-006: Footer
|
||||
|
||||
The dashboard shall display a footer with last-updated time and service identification.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Format: "Last updated: {timestamp} UTC | Service: ZB.MOM.WW.OtOpcUa.Host v{version}".
|
||||
- Timestamp is the server-side UTC time when the HTML was generated.
|
||||
- Version is read from the assembly version (`Assembly.GetExecutingAssembly().GetName().Version`).
|
||||
|
||||
---
|
||||
|
||||
## DASH-007: Auto-Refresh
|
||||
|
||||
The dashboard page shall auto-refresh to show current status without manual reload.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- HTML page includes `<meta http-equiv="refresh" content="10">` for 10-second auto-refresh.
|
||||
- No JavaScript required for refresh (pure HTML meta-refresh).
|
||||
- Refresh interval: configurable via `Dashboard:RefreshIntervalSeconds`, default 10 seconds.
|
||||
|
||||
---
|
||||
|
||||
## DASH-008: JSON Status API
|
||||
|
||||
The `/api/status` endpoint shall return a JSON object with all dashboard data for programmatic consumption.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Response Content-Type: `application/json`.
|
||||
- JSON structure includes: connection state, health status, subscription statistics, and operation metrics.
|
||||
- Same data as the HTML dashboard, structured for machine consumption.
|
||||
- Suitable for integration with external monitoring tools.
|
||||
|
||||
---
|
||||
|
||||
## DASH-009: Galaxy Info Panel
|
||||
|
||||
The dashboard shall display a Galaxy Info panel showing Galaxy Repository state.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Shows: **Galaxy Name** (e.g., ZB), **DB Status** (Connected/Disconnected), **Last Deploy** (timestamp from `galaxy.time_of_last_deploy`), **Objects** (count), **Attributes** (count), **Last Rebuild** (timestamp of last address space rebuild).
|
||||
- Provides visibility into the Galaxy Repository component's state independently of MXAccess connection status.
|
||||
|
||||
### Details
|
||||
|
||||
- "DB Status" reflects whether the most recent change detection poll succeeded.
|
||||
- "Last Deploy" shows the raw `time_of_last_deploy` value from the Galaxy database.
|
||||
- "Objects" and "Attributes" show counts from the most recent successful hierarchy/attribute query.
|
||||
A formal requirements-level doc for the Admin UI (AdminUiReqs.md) is not yet written — the design doc at `docs/v2/admin-ui.md` serves as the authoritative reference until formal cert-compliance requirements are needed.
|
||||
|
||||
528
docs/security.md
528
docs/security.md
@@ -1,15 +1,28 @@
|
||||
# Transport Security
|
||||
# Security
|
||||
|
||||
## Overview
|
||||
OtOpcUa has four independent security concerns. This document covers all four:
|
||||
|
||||
The LmxOpcUa server supports configurable transport security profiles that control how data is protected on the wire between OPC UA clients and the server.
|
||||
1. **Transport security** — OPC UA secure channel (signing, encryption, X.509 trust).
|
||||
2. **OPC UA authentication** — Anonymous / UserName / X.509 session identities; UserName tokens authenticated by LDAP bind.
|
||||
3. **Data-plane authorization** — who can browse, read, subscribe, write, acknowledge alarms on which nodes. Evaluated by `PermissionTrie` against the Config DB `NodeAcl` tree.
|
||||
4. **Control-plane authorization** — who can view or edit fleet configuration in the Admin UI. Gated by the `AdminRole` (`ConfigViewer` / `ConfigEditor` / `FleetAdmin`) claim from `LdapGroupRoleMapping`.
|
||||
|
||||
Transport security and OPC UA authentication are per-node concerns configured in the Server's bootstrap `appsettings.json`. Data-plane ACLs and Admin role grants live in the Config DB.
|
||||
|
||||
---
|
||||
|
||||
## Transport Security
|
||||
|
||||
### Overview
|
||||
|
||||
The OtOpcUa Server supports configurable OPC UA transport security profiles that control how data is protected on the wire between OPC UA clients and the server.
|
||||
|
||||
There are two distinct layers of security in OPC UA:
|
||||
|
||||
- **Transport security** -- secures the communication channel itself using TLS-style certificate exchange, message signing, and encryption. This is what the `Security` configuration section controls.
|
||||
- **UserName token encryption** -- protects user credentials (username/password) sent during session activation. The OPC UA stack encrypts UserName tokens using the server's application certificate regardless of the transport security mode. This means UserName authentication works on `None` endpoints too — the credentials themselves are always encrypted. However, a secure transport profile adds protection against message-level tampering and eavesdropping of data payloads.
|
||||
- **Transport security** -- secures the communication channel itself using TLS-style certificate exchange, message signing, and encryption. This is what the `OpcUaServer:SecurityProfile` setting controls.
|
||||
- **UserName token encryption** -- protects user credentials (username/password) sent during session activation. The OPC UA stack encrypts UserName tokens using the server's application certificate regardless of the transport security mode. UserName authentication therefore works on `None` endpoints too — the credentials themselves are always encrypted. A secure transport profile adds protection against message-level tampering and eavesdropping of data payloads.
|
||||
|
||||
## Supported Security Profiles
|
||||
### Supported security profiles
|
||||
|
||||
The server supports seven transport security profiles:
|
||||
|
||||
@@ -23,334 +36,88 @@ The server supports seven transport security profiles:
|
||||
| `Aes256_Sha256_RsaPss-Sign` | Aes256_Sha256_RsaPss | Sign | Strongest profile with AES-256 and RSA-PSS signatures. |
|
||||
| `Aes256_Sha256_RsaPss-SignAndEncrypt` | Aes256_Sha256_RsaPss | SignAndEncrypt | Strongest profile. Recommended for high-security deployments. |
|
||||
|
||||
Multiple profiles can be enabled simultaneously. The server exposes a separate endpoint for each configured profile, and clients select the one they prefer during connection.
|
||||
The server exposes a separate endpoint for each configured profile, and clients select the one they prefer during connection.
|
||||
|
||||
If no valid profiles are configured (or all names are unrecognized), the server falls back to `None` with a warning in the log.
|
||||
### Configuration
|
||||
|
||||
## Configuration
|
||||
|
||||
Transport security is configured in the `Security` section of `appsettings.json`:
|
||||
Transport security is configured in the `OpcUaServer` section of the Server process's bootstrap `appsettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"Security": {
|
||||
"Profiles": ["None"],
|
||||
"AutoAcceptClientCertificates": true,
|
||||
"RejectSHA1Certificates": true,
|
||||
"MinimumCertificateKeySize": 2048,
|
||||
"PkiRootPath": null,
|
||||
"CertificateSubject": null
|
||||
"OpcUaServer": {
|
||||
"EndpointUrl": "opc.tcp://0.0.0.0:4840/OtOpcUa",
|
||||
"ApplicationName": "OtOpcUa Server",
|
||||
"ApplicationUri": "urn:node-a:OtOpcUa",
|
||||
"PkiStoreRoot": "C:/ProgramData/OtOpcUa/pki",
|
||||
"AutoAcceptUntrustedClientCertificates": false,
|
||||
"SecurityProfile": "Basic256Sha256-SignAndEncrypt"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
The server certificate is auto-generated on first start if none exists in `PkiStoreRoot/own/`. Always generated even for `None`-only deployments because UserName token encryption depends on it.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|--------------------------------|------------|--------------------------------------------------|-------------|
|
||||
| `Profiles` | `string[]` | `["None"]` | List of security profile names to expose as server endpoints. Valid values: `None`, `Basic256Sha256-Sign`, `Basic256Sha256-SignAndEncrypt`, `Aes128_Sha256_RsaOaep-Sign`, `Aes128_Sha256_RsaOaep-SignAndEncrypt`, `Aes256_Sha256_RsaPss-Sign`, `Aes256_Sha256_RsaPss-SignAndEncrypt`. Profile names are case-insensitive. Duplicates are ignored. |
|
||||
| `AutoAcceptClientCertificates` | `bool` | `true` | When `true`, the server automatically trusts client certificates that are not already in the trusted store. Set to `false` in production for explicit trust management. |
|
||||
| `RejectSHA1Certificates` | `bool` | `true` | When `true`, client certificates signed with SHA-1 are rejected. SHA-1 is considered cryptographically weak. |
|
||||
| `MinimumCertificateKeySize` | `int` | `2048` | Minimum RSA key size (in bits) required for client certificates. Certificates with shorter keys are rejected. |
|
||||
| `PkiRootPath` | `string?` | `null` (defaults to `%LOCALAPPDATA%\OPC Foundation\pki`) | Override for the PKI root directory where certificates are stored. When `null`, uses the OPC Foundation default location. |
|
||||
| `CertificateSubject` | `string?` | `null` (defaults to `CN={ServerName}, O=ZB MOM, DC=localhost`) | Override for the server certificate subject name. When `null`, the subject is derived from the configured `ServerName`. |
|
||||
|
||||
### Example: Development (no security)
|
||||
|
||||
```json
|
||||
{
|
||||
"Security": {
|
||||
"Profiles": ["None"],
|
||||
"AutoAcceptClientCertificates": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Production (encrypted only)
|
||||
|
||||
```json
|
||||
{
|
||||
"Security": {
|
||||
"Profiles": ["Basic256Sha256-SignAndEncrypt"],
|
||||
"AutoAcceptClientCertificates": false,
|
||||
"RejectSHA1Certificates": true,
|
||||
"MinimumCertificateKeySize": 2048
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Mixed (sign and encrypt endpoints, no plaintext)
|
||||
|
||||
```json
|
||||
{
|
||||
"Security": {
|
||||
"Profiles": ["Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt"],
|
||||
"AutoAcceptClientCertificates": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## PKI Directory Layout
|
||||
|
||||
The server stores certificates in a directory-based PKI store. The default root is:
|
||||
### PKI directory layout
|
||||
|
||||
```
|
||||
%LOCALAPPDATA%\OPC Foundation\pki\
|
||||
```
|
||||
|
||||
This can be overridden with the `PkiRootPath` setting. The directory structure is:
|
||||
|
||||
```
|
||||
pki/
|
||||
{PkiStoreRoot}/
|
||||
own/ Server's own application certificate and private key
|
||||
issuer/ CA certificates that issued trusted client certificates
|
||||
trusted/ Explicitly trusted client (peer) certificates
|
||||
rejected/ Certificates that were presented but not trusted
|
||||
```
|
||||
|
||||
### Certificate Trust Flow
|
||||
### Certificate trust flow
|
||||
|
||||
When a client connects using a secure profile (`Sign` or `SignAndEncrypt`), the following trust evaluation occurs:
|
||||
|
||||
1. The client presents its application certificate during the secure channel handshake.
|
||||
2. The server checks whether the certificate exists in the `trusted/` store.
|
||||
3. If found, the connection proceeds (subject to key size and SHA-1 checks).
|
||||
4. If not found and `AutoAcceptClientCertificates` is `true`, the certificate is automatically copied to `trusted/` and the connection proceeds.
|
||||
5. If not found and `AutoAcceptClientCertificates` is `false`, the certificate is copied to `rejected/` and the connection is refused.
|
||||
6. Regardless of trust status, the certificate must meet the `MinimumCertificateKeySize` requirement and pass the SHA-1 check (if `RejectSHA1Certificates` is `true`).
|
||||
3. If found, the connection proceeds.
|
||||
4. If not found and `AutoAcceptUntrustedClientCertificates` is `true`, the certificate is automatically copied to `trusted/` and the connection proceeds.
|
||||
5. If not found and `AutoAcceptUntrustedClientCertificates` is `false`, the certificate is copied to `rejected/` and the connection is refused.
|
||||
|
||||
On first startup with a secure profile, the server automatically generates a self-signed application certificate in the `own/` directory if one does not already exist.
|
||||
The Admin UI `Certificates.razor` page uses `CertTrustService` (singleton reading `CertTrustOptions` for the Server's `PkiStoreRoot`) to promote rejected client certs to trusted without operators having to file-copy manually.
|
||||
|
||||
## Production Hardening
|
||||
### Production hardening
|
||||
|
||||
The default settings prioritize ease of development. Before deploying to production, apply the following changes:
|
||||
|
||||
### 1. Disable automatic certificate acceptance
|
||||
|
||||
Set `AutoAcceptClientCertificates` to `false` so that only explicitly trusted client certificates are accepted:
|
||||
|
||||
```json
|
||||
{
|
||||
"Security": {
|
||||
"AutoAcceptClientCertificates": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After changing this setting, you must manually copy each client's application certificate (the `.der` file) into the `trusted/` directory.
|
||||
|
||||
### 2. Remove the None profile
|
||||
|
||||
Remove `None` from the `Profiles` list to prevent unencrypted connections:
|
||||
|
||||
```json
|
||||
{
|
||||
"Security": {
|
||||
"Profiles": ["Aes256_Sha256_RsaPss-SignAndEncrypt"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Configure LDAP authentication
|
||||
|
||||
Enable LDAP authentication to validate credentials against the GLAuth server. LDAP group membership controls what each user can do (read, write, alarm acknowledgment). See [Configuration Guide](Configuration.md) for the full LDAP property reference.
|
||||
|
||||
```json
|
||||
{
|
||||
"Authentication": {
|
||||
"AllowAnonymous": false,
|
||||
"AnonymousCanWrite": false,
|
||||
"Ldap": {
|
||||
"Enabled": true,
|
||||
"Host": "localhost",
|
||||
"Port": 3893,
|
||||
"BaseDN": "dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
While UserName tokens are always encrypted by the OPC UA stack (using the server certificate), enabling a secure transport profile adds protection against message-level tampering and data eavesdropping.
|
||||
|
||||
### 4. Review the rejected certificate store
|
||||
|
||||
Periodically inspect the `rejected/` directory. Certificates that appear here were presented by clients but were not trusted. If you recognize a legitimate client certificate, move it to the `trusted/` directory to grant access.
|
||||
|
||||
## X.509 Certificate Authentication
|
||||
|
||||
The server supports X.509 certificate-based user authentication in addition to Anonymous and UserName tokens. When any non-None security profile is configured, the server advertises `UserTokenType.Certificate` in its endpoint descriptions.
|
||||
|
||||
Clients can authenticate by presenting an X.509 certificate. The server extracts the Common Name (CN) from the certificate subject and assigns the `AuthenticatedUser` and `ReadOnly` roles. The authentication is logged with the certificate's CN, subject, and thumbprint.
|
||||
|
||||
X.509 authentication is available automatically when transport security is enabled -- no additional configuration is required.
|
||||
|
||||
## Audit Logging
|
||||
|
||||
The server generates audit log entries for security-relevant operations. All audit entries use the `AUDIT:` prefix and are written to the Serilog rolling file sink for compliance review.
|
||||
|
||||
Audited events:
|
||||
- **Authentication success**: Logs username, assigned roles, and session ID
|
||||
- **Authentication failure**: Logs username and session ID
|
||||
- **X.509 authentication**: Logs certificate CN, subject, and thumbprint
|
||||
- **Certificate validation**: Logs certificate subject, thumbprint, and expiry for all validation events (accepted or rejected)
|
||||
- **Write access denial**: Logged by the role-based access control system when a user lacks the required role
|
||||
|
||||
Example audit log entries:
|
||||
```
|
||||
AUDIT: Authentication SUCCESS for user admin with roles [ReadOnly, WriteOperate, AlarmAck] session abc123
|
||||
AUDIT: Authentication FAILED for user baduser from session def456
|
||||
X509 certificate authenticated: CN=ClientApp, Subject=CN=ClientApp,O=Acme, Thumbprint=AB12CD34
|
||||
```
|
||||
|
||||
## CLI Examples
|
||||
|
||||
The Client CLI supports the `-S` (or `--security`) flag to select the transport security mode when connecting. Valid values are `none`, `sign`, `encrypt`, and `signandencrypt`.
|
||||
|
||||
### Connect with no security
|
||||
|
||||
```bash
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S none
|
||||
```
|
||||
|
||||
### Connect with signing
|
||||
|
||||
```bash
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S sign
|
||||
```
|
||||
|
||||
### Connect with signing and encryption
|
||||
|
||||
```bash
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S encrypt
|
||||
```
|
||||
|
||||
### Browse with encryption and authentication
|
||||
|
||||
```bash
|
||||
dotnet run -- browse -u opc.tcp://localhost:4840/LmxOpcUa -S encrypt -U operator -P secure-password -r -d 3
|
||||
```
|
||||
|
||||
### Read a node with signing
|
||||
|
||||
```bash
|
||||
dotnet run -- read -u opc.tcp://localhost:4840/LmxOpcUa -S sign -n "ns=2;s=TestMachine_001/Speed"
|
||||
```
|
||||
|
||||
The CLI tool auto-generates its own client certificate on first use (stored under `%LOCALAPPDATA%\OpcUaCli\pki\own\`). When connecting to a server with `AutoAcceptClientCertificates` set to `false`, you must copy the CLI tool's certificate into the server's `trusted/` directory before the connection will succeed.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Certificate trust failure
|
||||
|
||||
**Symptom:** The client receives a `BadSecurityChecksFailed` or `BadCertificateUntrusted` error when connecting.
|
||||
|
||||
**Cause:** The server does not trust the client's certificate (or vice versa), and `AutoAcceptClientCertificates` is `false`.
|
||||
|
||||
**Resolution:**
|
||||
1. Check the server's `rejected/` directory for the client's certificate file.
|
||||
2. Copy the `.der` file from `rejected/` to `trusted/`.
|
||||
3. Retry the connection.
|
||||
4. If the server's own certificate is not trusted by the client, copy the server's certificate from `pki/own/certs/` to the client's trusted store.
|
||||
|
||||
### Endpoint mismatch
|
||||
|
||||
**Symptom:** The client receives a `BadSecurityModeRejected` or `BadSecurityPolicyRejected` error, or reports "No endpoint found with security mode...".
|
||||
|
||||
**Cause:** The client is requesting a security mode that the server does not expose. For example, the client requests `SignAndEncrypt` but the server only has `None` configured.
|
||||
|
||||
**Resolution:**
|
||||
1. Verify the server's configured `Profiles` in `appsettings.json`.
|
||||
2. Ensure the profile matching the client's requested mode is listed (e.g., add `Basic256Sha256-SignAndEncrypt` for encrypted connections).
|
||||
3. Restart the server after changing the configuration.
|
||||
4. Use the CLI tool to verify available endpoints:
|
||||
```bash
|
||||
dotnet run -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S none
|
||||
```
|
||||
The output displays the security mode and policy of the connected endpoint.
|
||||
|
||||
### Server certificate not generated
|
||||
|
||||
**Symptom:** The server logs a warning about application certificate check failure on startup.
|
||||
|
||||
**Cause:** The `pki/own/` directory may not be writable, or the certificate generation failed.
|
||||
|
||||
**Resolution:**
|
||||
1. Ensure the service account has write access to the PKI root directory.
|
||||
2. Check that the `PkiRootPath` (if overridden) points to a valid, writable location.
|
||||
3. Delete any corrupt certificate files in `pki/own/` and restart the server to trigger regeneration.
|
||||
|
||||
### SHA-1 certificate rejection
|
||||
|
||||
**Symptom:** A client with a valid certificate is rejected, and the server logs mention SHA-1.
|
||||
|
||||
**Cause:** The client's certificate was signed with SHA-1, and `RejectSHA1Certificates` is `true` (the default).
|
||||
|
||||
**Resolution:**
|
||||
- Regenerate the client certificate using SHA-256 or stronger (recommended).
|
||||
- Alternatively, set `RejectSHA1Certificates` to `false` in the server configuration (not recommended for production).
|
||||
- Set `AutoAcceptUntrustedClientCertificates = false`.
|
||||
- Drop `None` from the profile set.
|
||||
- Use the Admin UI to promote trusted client certs rather than the auto-accept fallback.
|
||||
- Periodically audit the `rejected/` directory; an unexpected entry is often a misconfigured client or a probe attempt.
|
||||
|
||||
---
|
||||
|
||||
## LDAP Authentication
|
||||
## OPC UA Authentication
|
||||
|
||||
The server supports LDAP-based user authentication via GLAuth (or any standard LDAP server). When enabled, OPC UA `UserName` token credentials are validated by LDAP bind. LDAP group membership is resolved once during authentication and mapped to custom OPC UA role `NodeId`s in the `urn:zbmom:lmxopcua:roles` namespace. These role NodeIds are stored on the session's `RoleBasedIdentity.GrantedRoleIds` and checked directly during write and alarm-ack operations.
|
||||
The Server accepts three OPC UA identity-token types:
|
||||
|
||||
### Architecture
|
||||
| Token | Handler | Notes |
|
||||
|---|---|---|
|
||||
| Anonymous | `IUserAuthenticator.AuthenticateAsync(username: "", password: "")` | Refused in strict mode unless explicit anonymous grants exist; allowed in lax mode for backward compatibility. |
|
||||
| UserName/Password | `LdapUserAuthenticator` (`src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs`) | LDAP bind + group lookup; resolved `LdapGroups` flow into the session's identity bearer (`ILdapGroupsBearer`). |
|
||||
| X.509 Certificate | Stack-level acceptance + role mapping via CN | X.509 identity carries `AuthenticatedUser` + read roles; finer-grain authorization happens through the data-plane ACLs. |
|
||||
|
||||
```
|
||||
OPC UA Client → UserName Token → LmxOpcUa Server → LDAP Bind (validate credentials)
|
||||
→ LDAP Search (resolve group membership)
|
||||
→ Map groups to OPC UA role NodeIds
|
||||
→ Store on RoleBasedIdentity.GrantedRoleIds
|
||||
→ Permission checks via GrantedRoleIds.Contains()
|
||||
### LDAP bind flow (`LdapUserAuthenticator`)
|
||||
|
||||
`Program.cs` in the Server registers the authenticator based on `OpcUaServer:Ldap`:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddSingleton<IUserAuthenticator>(sp => ldapOptions.Enabled
|
||||
? new LdapUserAuthenticator(ldapOptions, sp.GetRequiredService<ILogger<LdapUserAuthenticator>>())
|
||||
: new DenyAllUserAuthenticator());
|
||||
```
|
||||
|
||||
### LDAP Groups and OPC UA Permissions
|
||||
`LdapUserAuthenticator`:
|
||||
|
||||
All authenticated LDAP users can browse and read nodes regardless of group membership. Groups grant additional permissions:
|
||||
1. Refuses to bind over plain-LDAP unless `AllowInsecureLdap = true` (dev/test only).
|
||||
2. Connects to `Server:Port`, optionally upgrades to TLS (`UseTls = true`, port 636 for AD).
|
||||
3. Binds as the service account; searches `SearchBase` for `UserNameAttribute = username`.
|
||||
4. Rebinds as the resolved user DN with the supplied password (the actual credential check).
|
||||
5. Reads `GroupAttribute` (default `memberOf`) and strips the leading `CN=` so operators configure friendly group names in `GroupToRole`.
|
||||
6. Returns a `UserAuthResult` carrying the validated username + the set of LDAP groups. The set flows through to the session identity via `ILdapGroupsBearer.LdapGroups`.
|
||||
|
||||
| LDAP Group | Permission |
|
||||
|---|---|
|
||||
| ReadOnly | No additional permissions (read-only access) |
|
||||
| WriteOperate | Write FreeAccess and Operate attributes |
|
||||
| WriteTune | Write Tune attributes |
|
||||
| WriteConfigure | Write Configure attributes |
|
||||
| AlarmAck | Acknowledge alarms |
|
||||
|
||||
Users can belong to multiple groups. The `admin` user in the default GLAuth configuration belongs to all groups.
|
||||
|
||||
### Effective Permission Matrix
|
||||
|
||||
The effective permission for a write operation depends on two factors: the user's session role (from LDAP group membership or anonymous access) and the Galaxy attribute's security classification. The security classification controls the node's `AccessLevel` — attributes classified as `SecuredWrite`, `VerifiedWrite`, or `ViewOnly` are exposed as read-only nodes regardless of the user's role. For writable classifications, the required write role depends on the classification.
|
||||
|
||||
| | FreeAccess | Operate | SecuredWrite | VerifiedWrite | Tune | Configure | ViewOnly |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **Anonymous (`AnonymousCanWrite=true`)** | Write | Write | Read | Read | Write | Write | Read |
|
||||
| **Anonymous (`AnonymousCanWrite=false`)** | Read | Read | Read | Read | Read | Read | Read |
|
||||
| **ReadOnly** | Read | Read | Read | Read | Read | Read | Read |
|
||||
| **WriteOperate** | Write | Write | Read | Read | Read | Read | Read |
|
||||
| **WriteTune** | Read | Read | Read | Read | Write | Read | Read |
|
||||
| **WriteConfigure** | Read | Read | Read | Read | Read | Write | Read |
|
||||
| **AlarmAck** (only) | Read | Read | Read | Read | Read | Read | Read |
|
||||
| **Admin** (all groups) | Write | Write | Read | Read | Write | Write | Read |
|
||||
|
||||
All roles can browse and read all nodes. The "Read" entries above mean the node is either read-only by classification or the user lacks the required write role. "Write" means the write is permitted by both the node's classification and the user's role.
|
||||
|
||||
Alarm acknowledgment is an independent permission controlled by the `AlarmAck` role and is not affected by security classification.
|
||||
|
||||
### GLAuth Setup
|
||||
|
||||
The project uses [GLAuth](https://github.com/glauth/glauth) v2.4.0 as the LDAP server, installed at `C:\publish\glauth\`. See `C:\publish\glauth\auth.md` for the complete user/group reference and service management commands.
|
||||
|
||||
### Configuration
|
||||
|
||||
Enable LDAP in `appsettings.json` under `Authentication.Ldap`. See [Configuration Guide](Configuration.md) for the full property reference.
|
||||
|
||||
### Active Directory configuration
|
||||
|
||||
Production deployments typically point at Active Directory instead of GLAuth. Only four properties differ from the dev defaults: `Server`, `Port`, `UserNameAttribute`, and `ServiceAccountDn`. The same `GroupToRole` mechanism works — map your AD security groups to OPC UA roles.
|
||||
Configuration example (Active Directory production):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -362,32 +129,169 @@ Production deployments typically point at Active Directory instead of GLAuth. On
|
||||
"UseTls": true,
|
||||
"AllowInsecureLdap": false,
|
||||
"SearchBase": "DC=corp,DC=example,DC=com",
|
||||
"ServiceAccountDn": "CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com",
|
||||
"ServiceAccountDn": "CN=OtOpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com",
|
||||
"ServiceAccountPassword": "<from your secret store>",
|
||||
"DisplayNameAttribute": "displayName",
|
||||
"GroupAttribute": "memberOf",
|
||||
"UserNameAttribute": "sAMAccountName",
|
||||
"GroupToRole": {
|
||||
"OPCUA-Operators": "WriteOperate",
|
||||
"OPCUA-Engineers": "WriteConfigure",
|
||||
"OPCUA-AlarmAck": "AlarmAck",
|
||||
"OPCUA-Tuners": "WriteTune"
|
||||
"OPCUA-Tuners": "WriteTune",
|
||||
"OPCUA-AlarmAck": "AlarmAck"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
`UserNameAttribute: "sAMAccountName"` is the critical AD override — the default `uid` is not populated on AD user entries. Use `userPrincipalName` instead if operators log in with `user@corp.example.com` form. Nested group membership is not expanded — assign users directly to the role-mapped groups, or pre-flatten in AD.
|
||||
|
||||
- `UserNameAttribute: "sAMAccountName"` is the critical AD override — the default `uid` is not populated on AD user entries, so the user-DN lookup returns no results without it. Use `userPrincipalName` instead if operators log in with `user@corp.example.com` form.
|
||||
- `Port: 636` + `UseTls: true` is required under AD's LDAP-signing enforcement. AD increasingly rejects plain-LDAP bind; set `AllowInsecureLdap: false` to refuse fallback.
|
||||
- `ServiceAccountDn` should name a dedicated read-only service principal — not a privileged admin. The account needs read access to user and group entries in the search base.
|
||||
- `memberOf` values come back as full DNs like `CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com`. The authenticator strips the leading `CN=` RDN value so operators configure `GroupToRole` with readable group common-names.
|
||||
- Nested group membership is **not** expanded — assign users directly to the role-mapped groups, or pre-flatten membership in AD. `LDAP_MATCHING_RULE_IN_CHAIN` / `tokenGroups` expansion is an authenticator enhancement, not a config change.
|
||||
The same options bind the Admin's `LdapAuthService` (cookie auth / login form) so operators authenticate with a single credential across both processes.
|
||||
|
||||
### Security Considerations
|
||||
---
|
||||
|
||||
- LDAP credentials are transmitted in plaintext over the OPC UA channel unless transport security is enabled. Use `Basic256Sha256-SignAndEncrypt` for production deployments.
|
||||
- The GLAuth LDAP server itself listens on plain LDAP (port 3893). Enable LDAPS in `glauth.cfg` for environments where LDAP traffic crosses network boundaries.
|
||||
- The service account password is stored in `appsettings.json`. Protect this file with appropriate filesystem permissions.
|
||||
## Data-Plane Authorization
|
||||
|
||||
Data-plane authorization is the check run on every OPC UA operation against an OtOpcUa endpoint: *can this authenticated user Browse / Read / Subscribe / Write / HistoryRead / AckAlarm / Call on this specific node?*
|
||||
|
||||
Per decision #129 the model is **additive-only — no explicit Deny**. Grants at each hierarchy level union; absence of a grant is the default-deny.
|
||||
|
||||
### Hierarchy
|
||||
|
||||
ACLs are evaluated against the UNS path:
|
||||
|
||||
```
|
||||
ClusterId → Namespace → UnsArea → UnsLine → Equipment → Tag
|
||||
```
|
||||
|
||||
Each level can carry `NodeAcl` rows (`src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs`) that grant a permission bundle to a set of `LdapGroups`.
|
||||
|
||||
### Permission flags
|
||||
|
||||
```csharp
|
||||
[Flags]
|
||||
public enum NodePermissions : uint
|
||||
{
|
||||
Browse = 1 << 0,
|
||||
Read = 1 << 1,
|
||||
Subscribe = 1 << 2,
|
||||
HistoryRead = 1 << 3,
|
||||
WriteOperate = 1 << 4,
|
||||
WriteTune = 1 << 5,
|
||||
WriteConfigure = 1 << 6,
|
||||
AlarmRead = 1 << 7,
|
||||
AlarmAcknowledge = 1 << 8,
|
||||
AlarmConfirm = 1 << 9,
|
||||
AlarmShelve = 1 << 10,
|
||||
MethodCall = 1 << 11,
|
||||
|
||||
ReadOnly = Browse | Read | Subscribe | HistoryRead | AlarmRead,
|
||||
Operator = ReadOnly | WriteOperate | AlarmAcknowledge | AlarmConfirm,
|
||||
Engineer = Operator | WriteTune | AlarmShelve,
|
||||
Admin = Engineer | WriteConfigure | MethodCall,
|
||||
}
|
||||
```
|
||||
|
||||
The three Write tiers map to Galaxy's v1 `SecurityClassification` — `FreeAccess`/`Operate` → `WriteOperate`, `Tune` → `WriteTune`, `Configure` → `WriteConfigure`. `SecuredWrite` / `VerifiedWrite` / `ViewOnly` classifications remain read-only from OPC UA regardless of grant.
|
||||
|
||||
### Evaluator — `PermissionTrie`
|
||||
|
||||
`src/ZB.MOM.WW.OtOpcUa.Core/Authorization/`:
|
||||
|
||||
| Class | Role |
|
||||
|---|---|
|
||||
| `PermissionTrie` | Cluster-scoped trie; each node carries `(GroupId → NodePermissions)` grants. |
|
||||
| `PermissionTrieBuilder` | Builds a trie from the current `NodeAcl` rows in one pass. |
|
||||
| `PermissionTrieCache` | Per-cluster memoised trie; invalidated via `AclChangeNotifier` when the Admin publishes a draft that touches ACLs. |
|
||||
| `TriePermissionEvaluator` | Implements `IPermissionEvaluator.Authorize(session, operation, scope)` — walks from the root to the leaf for the supplied `NodeScope`, unions grants along the path, compares required permission to the union. |
|
||||
|
||||
`NodeScope` carries `(ClusterId, NamespaceId, AreaId, LineId, EquipmentId, TagId)`; any suffix may be null — a tag-level ACL is more specific than an area-level ACL but both contribute via union.
|
||||
|
||||
### Dispatch gate — `AuthorizationGate`
|
||||
|
||||
`src/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs` bridges the OPC UA stack's `ISystemContext.UserIdentity` to the evaluator. `DriverNodeManager` holds exactly one reference to it and calls `IsAllowed(identity, OpcUaOperation.*, NodeScope)` on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call path. A false return short-circuits the dispatch with `BadUserAccessDenied`.
|
||||
|
||||
Key properties:
|
||||
|
||||
- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else flows through `AuthorizationGate`.
|
||||
- **Fail-open-during-transition.** `StrictMode = false` (default during ACL rollouts) lets sessions without resolved LDAP groups proceed; flip `Authorization:StrictMode = true` in production once ACLs are populated.
|
||||
- **Evaluator stays pure.** `TriePermissionEvaluator` has no OPC UA stack dependency — it's tested directly from xUnit.
|
||||
|
||||
### Probe-this-permission (Admin UI)
|
||||
|
||||
`PermissionProbeService` (`src/ZB.MOM.WW.OtOpcUa.Admin/Services/PermissionProbeService.cs`) lets an operator ask "if a user with groups X, Y, Z asked to do operation O on node N, would it succeed?" The answer is rendered in the AclsTab "Probe" dialog — same evaluator, same trie, so the Admin UI answer and the live Server answer cannot disagree.
|
||||
|
||||
### Full model
|
||||
|
||||
See [`docs/v2/acl-design.md`](v2/acl-design.md) for the complete design: trie invalidation, flag semantics, per-path override rules, and the reasoning behind additive-only (no Deny).
|
||||
|
||||
---
|
||||
|
||||
## Control-Plane Authorization
|
||||
|
||||
Control-plane authorization governs **the Admin UI** — who can view fleet config, edit drafts, publish generations, manage cluster nodes + credentials.
|
||||
|
||||
Per decision #150 control-plane roles are **deliberately independent of data-plane ACLs**. An operator who can read every OPC UA tag in production may not be allowed to edit cluster config; conversely a ConfigEditor may not have any data-plane grants at all.
|
||||
|
||||
### Roles
|
||||
|
||||
`src/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminRoles.cs`:
|
||||
|
||||
| Role | Capabilities |
|
||||
|---|---|
|
||||
| `ConfigViewer` | Read-only access to drafts, generations, audit log, fleet status. |
|
||||
| `ConfigEditor` | ConfigViewer plus draft editing (UNS, equipment, tags, ACLs, driver instances, reservations, CSV imports). Cannot publish. |
|
||||
| `FleetAdmin` | ConfigEditor plus publish, cluster/node CRUD, credential management, role-grant management. |
|
||||
|
||||
Policies registered in Admin `Program.cs`:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddAuthorizationBuilder()
|
||||
.AddPolicy("CanEdit", p => p.RequireRole(AdminRoles.ConfigEditor, AdminRoles.FleetAdmin))
|
||||
.AddPolicy("CanPublish", p => p.RequireRole(AdminRoles.FleetAdmin));
|
||||
```
|
||||
|
||||
Razor pages and API endpoints gate with `[Authorize(Policy = "CanEdit")]` / `"CanPublish"`; nav-menu sections hide via `<AuthorizeView>`.
|
||||
|
||||
### Role grant source
|
||||
|
||||
Admin reads `LdapGroupRoleMapping` rows from the Config DB (`src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/LdapGroupRoleMapping.cs`) — the same pattern as the data-plane `NodeAcl` but scoped to Admin roles + (optionally) cluster scope for multi-site fleets. The `RoleGrants.razor` page lets FleetAdmins edit these mappings without leaving the UI.
|
||||
|
||||
---
|
||||
|
||||
## OTOPCUA0001 Analyzer — Compile-Time Guard
|
||||
|
||||
Per-capability resilience (retry, timeout, circuit-breaker, bulkhead) is applied by `CapabilityInvoker` in `src/ZB.MOM.WW.OtOpcUa.Core/Resilience/`. A driver-capability call made **outside** the invoker bypasses resilience entirely — which in production looks like inconsistent timeouts, un-wrapped retries, and unbounded blocking.
|
||||
|
||||
`OTOPCUA0001` (Roslyn analyzer at `src/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs`) fires as a compile-time **warning** when an `async`/`Task`-returning method on one of the seven guarded capability interfaces (`IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`, `IAlarmSource`, `IHistoryProvider`) is invoked **outside** a lambda passed to `CapabilityInvoker.ExecuteAsync` / `ExecuteWriteAsync` / `AlarmSurfaceInvoker.*`. The analyzer walks up the syntax tree from the call site, finds any enclosing invoker invocation, and verifies the call lives transitively inside that invocation's anonymous-function argument — a sibling pattern (do the call, then invoke `ExecuteAsync` on something unrelated nearby) does not satisfy the rule.
|
||||
|
||||
Five xUnit-v3 + Shouldly tests at `tests/ZB.MOM.WW.OtOpcUa.Analyzers.Tests` cover the common fail/pass shapes + the sibling-pattern regression guard.
|
||||
|
||||
The rule is intentionally scoped to async surfaces — pure in-memory accessors like `IHostConnectivityProbe.GetHostStatuses()` return synchronously and do not require the invoker wrap.
|
||||
|
||||
---
|
||||
|
||||
## Audit Logging
|
||||
|
||||
- **Server**: Serilog `AUDIT:` prefix on every authentication success/failure, certificate validation result, write access denial. Written alongside the regular rolling file sink.
|
||||
- **Admin**: `AuditLogService` writes `ConfigAuditLog` rows to the Config DB for every publish, rollback, cluster-node CRUD, credential rotation. Visible in the Audit page for operators with `ConfigViewer` or above.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Certificate trust failure
|
||||
|
||||
Check `{PkiStoreRoot}/rejected/` for the client's cert. Promote via Admin UI Certificates page, or copy the `.der` file manually to `trusted/`.
|
||||
|
||||
### LDAP users can connect but fail authorization
|
||||
|
||||
Verify (a) `OpcUaServer:Ldap:GroupAttribute` returns groups in the form `CN=MyGroup,…` (OtOpcUa strips the `CN=` for matching), (b) a `NodeAcl` grant exists at any level of the node's UNS path that unions to the required permission, (c) `Authorization:StrictMode` is correctly set for the deployment stage.
|
||||
|
||||
### LDAP bind rejected as "insecure"
|
||||
|
||||
Set `UseTls = true` + `Port = 636`, or temporarily flip `AllowInsecureLdap = true` in dev. Production Active Directory increasingly refuses plain-LDAP bind under LDAP-signing enforcement.
|
||||
|
||||
### `AuthorizationGate` denies every call after a publish
|
||||
|
||||
`AclChangeNotifier` invalidates the `PermissionTrieCache` on publish; a stuck cache is usually a missed notification. Restart the Server as a quick mitigation and file a bug — the design is to stay fresh without restarts.
|
||||
|
||||
108
docs/v2/implementation/exit-gate-phase-2-closed.md
Normal file
108
docs/v2/implementation/exit-gate-phase-2-closed.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Phase 2 Close-Out (2026-04-20)
|
||||
|
||||
> Supersedes `exit-gate-phase-2-final.md` (2026-04-18) which captured the state at PR 2
|
||||
> merge. Between that doc and today, PR 4 closed all open high + medium findings, PR 13
|
||||
> shipped the probe manager, PR 14 shipped the alarm subsystem, and PR 61 closed the v1
|
||||
> archive deletion. Phase 2 is closed.
|
||||
|
||||
## Status: **CLOSED**
|
||||
|
||||
Every stream in Phase 2 is complete. Every finding from the 2026-04-18 adversarial review
|
||||
is resolved. The v1 archive is deleted. The Galaxy driver runs the full
|
||||
`Shared` / `Host` / `Proxy` topology against live MXAccess on the dev box with all 9
|
||||
capability interfaces wired end-to-end.
|
||||
|
||||
## Stream-by-stream
|
||||
|
||||
| Stream | Plan §reference | Status | Close commit |
|
||||
|---|---|---|---|
|
||||
| A — Driver.Galaxy.Shared | §A.1–A.3 | ✅ Complete | PR 1 |
|
||||
| B — Driver.Galaxy.Host | §B.1–B.10 | ✅ Complete — real Win32 pump, Tier C protections, all 3 IGalaxyBackend impls (Stub / DbBacked / MxAccess), probe manager, alarm tracker, Historian wire-up | PR 1 + PR 4 + PR 12 + PR 13 + PR 14 |
|
||||
| C — Driver.Galaxy.Proxy | §C.1–C.4 | ✅ Complete — all 9 capability interfaces, supervisor (Backoff + CircuitBreaker + HeartbeatMonitor), subscription push frames | PR 1 + PR 4 |
|
||||
| D — Retire legacy Host | §D.1–D.3 | ✅ Complete — archive markings landed in PR 2, source tree deletion in Phase 3 PR 18, status doc closed in PR 61 | PR 2 → Phase 3 PR 18 → PR 61 |
|
||||
| E — Parity validation | §E.1–E.4 | ✅ Complete — E2E suite + 4 stability-finding regression tests + `HostSubprocessParityTests` cross-FX integration | PR 2 |
|
||||
|
||||
## 2026-04-18 adversarial findings — resolved
|
||||
|
||||
All four `High` + `Medium` items flagged as OPEN at the 2026-04-18 exit gate closed in PR 4
|
||||
(`caa9cb8 Phase 2 PR 4 — close the 4 open high/medium MXAccess findings from
|
||||
exit-gate-phase-2-final.md`):
|
||||
|
||||
| ID | Finding | Resolution |
|
||||
|----|---------|------------|
|
||||
| High 1 | MxAccess Read subscription-leak on cancellation | One-shot read now wraps subscribe → first `OnDataChange` → unsubscribe in try/finally. Per-tag callback always detached. If the read installed the underlying subscription (prior `_addressToHandle` key was absent) it tears it down on the way out — no leaked probe item handles on caller cancel or timeout. |
|
||||
| High 2 | No MXAccess reconnect loop, only supervisor-driven recycle | `MxAccessClient` gains `MxAccessClientOptions { AutoReconnect, MonitorInterval=5s, StaleThreshold=60s }` + a background `MonitorLoopAsync` started on first `ConnectAsync`. Checks `_lastObservedActivityUtc` each interval (bumped by every `OnDataChange` callback); if stale, probes the proxy with a no-op COM `AddItem("$Heartbeat")` on the StaPump; on probe failure does reconnect-with-replay — Unregister (best-effort), Register, snapshot `_addressToHandle.Keys`, clear, re-AddItem every previously-active subscription. `ConnectionStateChanged` fires on the false→true transition; `ReconnectCount` bumps. |
|
||||
| Medium 3 | `SubscribeAsync` doesn't push `OnDataChange` frames yet | `IGalaxyBackend` gains `OnDataChange` / `OnAlarmEvent` / `OnHostStatusChanged` events. New `IFrameHandler.AttachConnection(FrameWriter)` called per-connection by `PipeServer` after Hello. `GalaxyFrameHandler.ConnectionSink` subscribes the events for the connection lifetime, fire-and-forgets pushes as `MessageKind.OnDataChangeNotification` / `AlarmEvent` / `RuntimeStatusChange` frames through the writer, swallows `ObjectDisposedException` for dispose race, unsubscribes on Dispose. `MxAccessGalaxyBackend.SubscribeAsync` wires `OnTagValueChanged` that fans values out per-tag to every subscription listening (one MXAccess subscription, multi-fan-out via `_refToSubs` reverse map). `UnsubscribeAsync` only calls `mx.UnsubscribeAsync` when the last sub for a tag drops. |
|
||||
| Medium 4 | `WriteValuesAsync` doesn't await `OnWriteComplete` | `MxAccessClient.WriteAsync` rewritten to return `Task<bool>` via the v1-style TCS-keyed-by-item-handle pattern in `_pendingWrites`. TCS added before the `Write` call, awaited with configurable timeout (default 5s), removed in finally. Returns true only when `OnWriteComplete` reported success. `MxAccessGalaxyBackend.WriteValuesAsync` reports per-tag `Bad_InternalError` ("MXAccess runtime reported write failure") when the bool returns false. |
|
||||
|
||||
## Cross-cutting deferrals — resolved
|
||||
|
||||
| Deferral | Resolution |
|
||||
|----------|------------|
|
||||
| Deletion of v1 archive | Phase 3 PR 18 deleted the source trees; PR 61 closed `V1_ARCHIVE_STATUS.md` |
|
||||
| Wonderware Historian SDK plugin port | `Driver.Galaxy.Host/Backend/Historian/` ports the 10 source files (`HistorianDataSource`, `HistorianClusterEndpointPicker`, `HistorianHealthSnapshot`, etc.). `MxAccessGalaxyBackend` implements `HistoryReadAsync` / `HistoryReadProcessedAsync` / `HistoryReadAtTimeAsync` / `HistoryReadEventsAsync`. `GalaxyProxyDriver.MapAggregateToColumn` translates `HistoryAggregateType` → `AnalogSummaryQuery` column names on the proxy side so Host stays OPC-UA-free. |
|
||||
| MxAccess subscription push frames | Closed under Medium 3 above |
|
||||
| Wonderware Historian-backed HistoryRead | Closed under the Historian port row |
|
||||
| Alarm subsystem wire-up | PR 14. `GalaxyAlarmTracker` in `Backend/Alarms/` advises the four Galaxy alarm-state attributes per `IsAlarm=true` attribute (`.InAlarm`, `.Priority`, `.DescAttrName`, `.Acked`), runs the OPC UA Part 9 lifecycle simplified for the Galaxy AlarmExtension model, raises `AlarmTransition` events (Active / Acknowledged / Inactive) forwarded through the existing `OnAlarmEvent` IPC frame. `AcknowledgeAlarmAsync` writes operator comment to `<tag>.AckMsg` through the PR 4 TCS-by-handle write path. |
|
||||
| Reconnect-without-recycle in MxAccessClient | Closed under High 2 (reconnect-with-replay loop is the "without-recycle" path — supervisor recycle remains the fallback). |
|
||||
| Real downstream-consumer cutover | Out of scope for this repo; phased Year-3 rollout per `docs/v2/plan.md` §Rollout — not a Phase 2 deliverable. |
|
||||
|
||||
## 2026-04-20 test baseline
|
||||
|
||||
Full-solution `dotnet test ZB.MOM.WW.OtOpcUa.slnx` on `v2` tip:
|
||||
|
||||
| Project | Pass | Skip | Target |
|
||||
|---|---:|---:|---|
|
||||
| Core.Abstractions.Tests | 37 | 0 | net10 |
|
||||
| Client.Shared.Tests | 136 | 0 | net10 |
|
||||
| Client.CLI.Tests | 52 | 0 | net10 |
|
||||
| Client.UI.Tests | 98 | 0 | net10 |
|
||||
| Driver.S7.Tests | 58 | 0 | net10 |
|
||||
| Driver.Modbus.Tests | 182 | 0 | net10 |
|
||||
| Driver.Modbus.IntegrationTests | 2 | 21 | net10 (Docker-gated) |
|
||||
| Driver.AbLegacy.Tests | 96 | 0 | net10 |
|
||||
| Driver.AbCip.Tests | 211 | 0 | net10 |
|
||||
| Driver.AbCip.IntegrationTests | 11 | 1 | net10 (ab_server-gated) |
|
||||
| Driver.TwinCAT.Tests | 110 | 0 | net10 |
|
||||
| Driver.OpcUaClient.Tests | 78 | 0 | net10 |
|
||||
| Driver.FOCAS.Tests | 119 | 0 | net10 |
|
||||
| Driver.Galaxy.Shared.Tests | 6 | 0 | net10 |
|
||||
| Driver.Galaxy.Proxy.Tests | 18 | 7 | net10 (live-Galaxy-gated) |
|
||||
| **Driver.Galaxy.Host.Tests** | **107** | **0** | **net48 x86** |
|
||||
| Analyzers.Tests | 5 | 0 | net10 |
|
||||
| Core.Tests | 182 | 0 | net10 |
|
||||
| Configuration.Tests | 71 | 0 | net10 |
|
||||
| Admin.Tests | 92 | 0 | net10 |
|
||||
| Server.Tests | 173 | 0 | net10 |
|
||||
| **Total** | **1844** | **29** | |
|
||||
|
||||
**Observed flake**: one Configuration.Tests failure on the first full-solution run turned
|
||||
green on re-run. Not a stable regression; logged as a known flake until it reproduces.
|
||||
|
||||
**Skips are all infra-gated**:
|
||||
- Modbus 21 skips — oitc/modbus-server Docker container not started.
|
||||
- AbCip 1 skip — libplctag `ab_server` binary not on PATH.
|
||||
- Galaxy.Proxy 7 skips — live Galaxy stack not reachable from the current shell (admin-token pipe ACL).
|
||||
|
||||
## What "Phase 2 closed" means for Phase 3 and later
|
||||
|
||||
- Galaxy runs as first-class v2 driver, same capability-interface contract as Modbus / S7 /
|
||||
AbCip / AbLegacy / TwinCAT / FOCAS / OpcUaClient.
|
||||
- No v1 code path remains. Anything invoking the `ZB.MOM.WW.LmxOpcUa.*` namespaces is
|
||||
historical; any future work routes through `Driver.Galaxy.Proxy` + the named-pipe IPC.
|
||||
- The 2026-04-13 stability findings live on as named regression tests under
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/StabilityFindingsRegressionTests.cs` — a
|
||||
future refactor that reintroduces any of those four defects trips the test.
|
||||
- Aveva Historian integration is wired end-to-end; new driver families don't need
|
||||
Historian-specific plumbing in the IPC — they just implement `IHistoryProvider`.
|
||||
|
||||
## Outstanding — not Phase 2 blockers
|
||||
|
||||
- **AB CIP whole-UDT read optimization** (task #194) — niche performance win for large UDT
|
||||
reads; current per-member fan-out works correctly.
|
||||
- **AB CIP `IAlarmSource` via tag-projected ALMA/ALMD** (task #177) — AB CIP driver doesn't
|
||||
currently expose alarms; feature-flagged follow-up.
|
||||
- **IdentificationFolderBuilder wire-in** (task #195) — blocked on Equipment node walker.
|
||||
- **UnsTab Playwright E2E** (task #199) — infra setup PR.
|
||||
|
||||
None of these are Phase 2 scope; all are tracked independently.
|
||||
@@ -1,5 +1,11 @@
|
||||
# Phase 2 Final Exit Gate (2026-04-18)
|
||||
|
||||
> **⚠️ Superseded by [`exit-gate-phase-2-closed.md`](exit-gate-phase-2-closed.md) (2026-04-20).**
|
||||
> This doc captures the snapshot at PR 2 merge — when the four `High` + `Medium` findings
|
||||
> in the adversarial review were still OPEN and Historian port + alarm subsystem were still
|
||||
> deferred. All of those closed subsequently (PR 4 + PR 12 + PR 13 + PR 14 + PR 61). Kept
|
||||
> as historical evidence; consult the close-out doc for current Phase 2 status.
|
||||
|
||||
> Supersedes `phase-2-partial-exit-evidence.md` and `exit-gate-phase-2.md`. Captures the
|
||||
> as-built state at the close of Phase 2 work delivered across two PRs.
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# Phase 6.4 — Admin UI Completion
|
||||
|
||||
> **Status**: DRAFT — Phase 1 Stream E shipped the Admin scaffold + core pages; several feature-completeness items from its completion checklist (`phase-1-configuration-and-admin-scaffold.md` §Stream E) never landed. This phase closes them.
|
||||
> **Status**: **SHIPPED (data layer)** 2026-04-19 — Stream A.2 (UnsImpactAnalyzer + DraftRevisionToken) and Stream B.1 (EquipmentCsvImporter parser) merged to `v2` in PR #91. Exit gate in PR #92.
|
||||
>
|
||||
> Deferred follow-ups (Blazor UI + staging tables + address-space wiring):
|
||||
> - Stream A UI — UnsTab MudBlazor drag/drop + 409 concurrent-edit modal + Playwright smoke (task #153).
|
||||
> - Stream B follow-up — EquipmentImportBatch staging + FinaliseImportBatch transaction + CSV import UI (task #155).
|
||||
> - Stream C — DiffViewer refactor into base + 6 section plugins + 1000-row cap + SignalR paging (task #156).
|
||||
> - Stream D — IdentificationFields.razor + DriverNodeManager OPC 40010 sub-folder exposure (task #157).
|
||||
>
|
||||
> Baseline pre-Phase-6.4: 1137 solution tests → post-Phase-6.4 data layer: 1159 passing (+22).
|
||||
>
|
||||
> **Branch**: `v2/phase-6-4-admin-ui-completion`
|
||||
> **Estimated duration**: 2 weeks
|
||||
|
||||
@@ -736,7 +736,7 @@ Each step leaves the system runnable. The generic extraction is effectively free
|
||||
6. **Wire `Server`** — bootstrap from Configuration using an instance-bound credential (cert/gMSA/SQL login), fail fast if the credential is rejected, register drivers, start Core.
|
||||
7. **Scaffold `Admin`** — Blazor Server app with: instance + credential management, draft/publish/rollback generation workflow (diff viewer, "publish to fleet", per-instance override), and core CRUD for drivers/devices/tags. Driver-specific config screens deferred to later phases.
|
||||
|
||||
**Phase 2 — Galaxy driver (prove the refactor)**
|
||||
**Phase 2 — Galaxy driver (prove the refactor) — ✅ CLOSED 2026-04-20** (see [`implementation/exit-gate-phase-2-closed.md`](implementation/exit-gate-phase-2-closed.md))
|
||||
8. **Build `Galaxy.Shared`** — .NET Standard 2.0 IPC message contracts
|
||||
9. **Build `Galaxy.Host`** — .NET 4.8 x86 process hosting MxAccessBridge, GalaxyRepository, alarms, HDA with IPC server
|
||||
10. **Build `Galaxy.Proxy`** — .NET 10 in-process proxy implementing IDriver interfaces, forwarding over IPC
|
||||
|
||||
@@ -189,6 +189,43 @@ Modbus has no native String, DateTime, or Int64 — those rows are skipped on th
|
||||
- **ab_server tag-type coverage is finite** (BOOL, DINT, REAL, arrays, basic strings). UDTs and `Program:` scoping are not fully implemented. Document an "ab_server-supported tag set" in the harness and exclude the rest from default CI; UDT coverage moves to the Studio 5000 Emulate golden-box tier.
|
||||
- CIP has no native subscriptions, so polling behavior matches real hardware.
|
||||
|
||||
### CI fixture (task #180)
|
||||
|
||||
The integration harness at `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/` exposes two test-time contracts:
|
||||
|
||||
- **`AbServerFixture(AbServerProfile)`** — starts the simulator with the CLI args composed from the profile's `--plc` family + seed-tag set. One fixture instance per family, one simulator process per test case (smoke tier). For larger suites that can share a simulator across several reads/writes, use a `IClassFixture<AbServerFixture>` wrapper per family.
|
||||
- **`KnownProfiles.{ControlLogix, CompactLogix, Micro800, GuardLogix}`** — the four per-family profiles. Drives the simulator's `--plc` mode + the preseed `--tag name:type[:size]` set. Micro800 + GuardLogix fall back to `controllogix` under the hood because ab_server has no dedicated mode for them — the driver-side family profile still enforces the narrower connection shape / safety classification separately.
|
||||
|
||||
**Pinned version** (recorded in `ci/ab-server.lock.json` so drift is one-file visible):
|
||||
|
||||
- `libplctag` **v2.6.16** (published 2026-03-29) — `ab_server.exe` ships inside the `_tools.zip` asset alongside `plctag.dll` + two `list_tags_*` helpers.
|
||||
- Windows x64: `libplctag_2.6.16_windows_x64_tools.zip` — SHA256 `9b78a3dee73d9cd28ca348c090f453dbe3ad9d07ad6bf42865a9dc3a79bc2232`
|
||||
- Windows x86: `libplctag_2.6.16_windows_x86_tools.zip` — SHA256 `fdfefd58b266c5da9a1ded1a430985e609289c9e67be2544da7513b668761edf`
|
||||
- Windows ARM64: `libplctag_2.6.16_windows_arm64_tools.zip` — SHA256 `d747728e4c4958bb63b4ac23e1c820c4452e4778dfd7d58f8a0aecd5402d4944`
|
||||
|
||||
**CI step:**
|
||||
|
||||
```yaml
|
||||
# GitHub Actions step placed before `dotnet test`:
|
||||
- name: Fetch ab_server (libplctag v2.6.16)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$pin = Get-Content ci/ab-server.lock.json | ConvertFrom-Json
|
||||
$asset = $pin.assets.'windows-x64' # swap to windows-x86 / windows-arm64 on non-x64 runners
|
||||
$url = "https://github.com/libplctag/libplctag/releases/download/$($pin.tag)/$($asset.file)"
|
||||
$zip = Join-Path $env:RUNNER_TEMP 'libplctag-tools.zip'
|
||||
Invoke-WebRequest $url -OutFile $zip
|
||||
$actual = (Get-FileHash -Algorithm SHA256 $zip).Hash.ToLower()
|
||||
if ($actual -ne $asset.sha256) { throw "libplctag tools SHA256 mismatch: expected $($asset.sha256), got $actual" }
|
||||
$dest = Join-Path $env:RUNNER_TEMP 'libplctag-tools'
|
||||
Expand-Archive $zip -DestinationPath $dest
|
||||
Add-Content $env:GITHUB_PATH $dest
|
||||
```
|
||||
|
||||
The fixture's `LocateBinary()` picks the binary up off PATH so the C# harness doesn't own the download — CI YAML is the right place for version pinning + hash verification. Developer workstations install the binary once from source (`cmake + make ab_server` under a libplctag clone) and the same fixture works identically.
|
||||
|
||||
Tests without ab_server on PATH are marked `Skip` via `AbServerFactAttribute` / `AbServerTheoryAttribute`, so fresh-clone runs without the simulator still pass all unit suites in this project.
|
||||
|
||||
---
|
||||
|
||||
## 3. Allen-Bradley Legacy (SLC 500 / MicroLogix, PCCC)
|
||||
|
||||
109
docs/v2/v2-release-readiness.md
Normal file
109
docs/v2/v2-release-readiness.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# v2 Release Readiness
|
||||
|
||||
> **Last updated**: 2026-04-19 (all three release blockers CLOSED — Phase 6.3 Streams A/C core shipped)
|
||||
> **Status**: **RELEASE-READY (code-path)** for v2 GA — all three code-path release blockers are closed. Remaining work is manual (client interop matrix, deployment checklist signoff, OPC UA CTT pass) + hardening follow-ups; see exit-criteria checklist below.
|
||||
|
||||
This doc is the single view of where v2 stands against its release criteria. Update it whenever a deferred follow-up closes or a new release blocker is discovered.
|
||||
|
||||
## Release-readiness dashboard
|
||||
|
||||
| Phase | Shipped | Status |
|
||||
|---|---|---|
|
||||
| Phase 0 — Rename + entry gate | ✓ | Shipped |
|
||||
| Phase 1 — Configuration + Admin scaffold | ✓ | Shipped (some UI items deferred to 6.4) |
|
||||
| Phase 2 — Galaxy driver split (Proxy/Host/Shared) | ✓ | Shipped |
|
||||
| Phase 3 — OPC UA server + LDAP + security profiles | ✓ | Shipped |
|
||||
| Phase 4 — Redundancy scaffold (entities + endpoints) | ✓ | Shipped (runtime closes in 6.3) |
|
||||
| Phase 5 — Drivers | ⚠ partial | Galaxy / Modbus / S7 / OpcUaClient shipped; AB CIP / AB Legacy / TwinCAT / FOCAS deferred (task #120) |
|
||||
| Phase 6.1 — Resilience & Observability | ✓ | **SHIPPED** (PRs #78–83) |
|
||||
| Phase 6.2 — Authorization runtime | ◐ core | **SHIPPED (core)** (PRs #84–88); dispatch wiring + Admin UI deferred |
|
||||
| Phase 6.3 — Redundancy runtime | ◐ core | **SHIPPED (core)** (PRs #89–90); coordinator + UA-node wiring + Admin UI + interop deferred |
|
||||
| Phase 6.4 — Admin UI completion | ◐ data layer | **SHIPPED (data layer)** (PRs #91–92); Blazor UI + OPC 40010 address-space wiring deferred |
|
||||
|
||||
**Aggregate test counts:** 906 baseline (pre-Phase-6) → **1159 passing** across Phase 6. One pre-existing Client.CLI `SubscribeCommandTests.Execute_PrintsSubscriptionMessage` flake tracked separately.
|
||||
|
||||
## Release blockers (must close before v2 GA)
|
||||
|
||||
Ordered by severity + impact on production fitness.
|
||||
|
||||
### ~~Security — Phase 6.2 dispatch wiring~~ (task #143 — **CLOSED** 2026-04-19, PR #94)
|
||||
|
||||
**Closed**. `AuthorizationGate` + `NodeScopeResolver` now thread through `OpcUaApplicationHost → OtOpcUaServer → DriverNodeManager`. `OnReadValue` + `OnWriteValue` + all four HistoryRead paths call `gate.IsAllowed(identity, operation, scope)` before the invoker. Production deployments activate enforcement by constructing `OpcUaApplicationHost` with an `AuthorizationGate(StrictMode: true)` + populating the `NodeAcl` table.
|
||||
|
||||
Additional Stream C surfaces (not release-blocking, hardening only):
|
||||
|
||||
- Browse + TranslateBrowsePathsToNodeIds gating with ancestor-visibility logic per `acl-design.md` §Browse.
|
||||
- CreateMonitoredItems + TransferSubscriptions gating with per-item `(AuthGenerationId, MembershipVersion)` stamp so revoked grants surface `BadUserAccessDenied` within one publish cycle (decision #153).
|
||||
- Alarm Acknowledge / Confirm / Shelve gating.
|
||||
- Call (method invocation) gating.
|
||||
- Finer-grained scope resolution — current `NodeScopeResolver` returns a flat cluster-level scope. Joining against the live Configuration DB to populate UnsArea / UnsLine / Equipment path is tracked as Stream C.12.
|
||||
- 3-user integration matrix covering every operation × allow/deny.
|
||||
|
||||
These are additional hardening — the three highest-value surfaces (Read / Write / HistoryRead) are now gated, which covers the base-security gap for v2 GA.
|
||||
|
||||
### ~~Config fallback — Phase 6.1 Stream D wiring~~ (task #136 — **CLOSED** 2026-04-19, PR #96)
|
||||
|
||||
**Closed**. `SealedBootstrap` consumes `ResilientConfigReader` + `GenerationSealedCache` + `StaleConfigFlag` end-to-end: bootstrap calls go through the timeout → retry → fallback-to-sealed pipeline; every central-DB success writes a fresh sealed snapshot so the next cache-miss has a known-good fallback; `StaleConfigFlag.IsStale` is now consumed by `HealthEndpointsHost.usingStaleConfig` so `/healthz` body reports reality.
|
||||
|
||||
Production activation: Program.cs switches `NodeBootstrap → SealedBootstrap` + constructs `OpcUaApplicationHost` with the `StaleConfigFlag` as an optional ctor parameter.
|
||||
|
||||
Remaining follow-ups (hardening, not release-blocking):
|
||||
|
||||
- A `HostedService` that polls `sp_GetCurrentGenerationForCluster` periodically so peer-published generations land in this node's cache without a restart.
|
||||
- Richer snapshot payload via `sp_GetGenerationContent` so fallback can serve the full generation content (DriverInstance enumeration, ACL rows, etc.) from the sealed cache alone.
|
||||
|
||||
### ~~Redundancy — Phase 6.3 Streams A/C core~~ (tasks #145 + #147 — **CLOSED** 2026-04-19, PRs #98–99)
|
||||
|
||||
**Closed**. The runtime orchestration layer now exists end-to-end:
|
||||
|
||||
- `RedundancyCoordinator` reads `ClusterNode` + peer list at startup (Stream A shipped in PR #98). Invariants enforced: 1-2 nodes (decision #83), unique ApplicationUri (#86), ≤1 Primary in Warm/Hot (#84). Startup fails fast on violation; runtime refresh logs + flips `IsTopologyValid=false` so the calculator falls to band 2 without tearing down.
|
||||
- `RedundancyStatePublisher` orchestrates topology + apply lease + recovery state + peer reachability through `ServiceLevelCalculator` + emits `OnStateChanged` / `OnServerUriArrayChanged` edge-triggered events (Stream C core shipped in PR #99). The OPC UA `ServiceLevel` Byte variable + `ServerUriArray` String[] variable subscribe to these events.
|
||||
|
||||
Remaining Phase 6.3 surfaces (hardening, not release-blocking):
|
||||
|
||||
- `PeerHttpProbeLoop` + `PeerUaProbeLoop` HostedServices that poll the peer + write to `PeerReachabilityTracker` on each tick. Without these the publisher sees `PeerReachability.Unknown` for every peer → Isolated-Primary band (230) even when the peer is up. Safe default (retains authority) but not the full non-transparent-redundancy UX.
|
||||
- OPC UA variable-node wiring layer: bind the `ServiceLevel` Byte node + `ServerUriArray` String[] node to the publisher's events via `BaseDataVariable.OnReadValue` / direct value push. Scoped follow-up on the Opc.Ua.Server stack integration.
|
||||
- `sp_PublishGeneration` wraps its apply in `await using var lease = coordinator.BeginApplyLease(...)` so the `PrimaryMidApply` band (200) fires during actual publishes (task #148 part 2).
|
||||
- Client interop matrix validation — Ignition / Kepware / Aveva OI Gateway (Stream F, task #150). Manual + doc-only work; doesn't block code ship.
|
||||
|
||||
### Remaining drivers (task #120)
|
||||
|
||||
AB CIP, AB Legacy, TwinCAT ADS, FOCAS drivers are planned but unshipped. Decision pending on whether these are release-blocking for v2 GA or can slip to a v2.1 follow-up.
|
||||
|
||||
## Nice-to-haves (not release-blocking)
|
||||
|
||||
- **Admin UI** — Phase 6.1 Stream E.2/E.3 (`/hosts` column refresh), Phase 6.2 Stream D (`RoleGrantsTab` + `AclsTab` Probe), Phase 6.3 Stream E (`RedundancyTab`), Phase 6.4 Streams A/B UI pieces, Stream C DiffViewer, Stream D `IdentificationFields.razor`. Tasks #134, #144, #149, #153, #155, #156, #157.
|
||||
- **Background services** — Phase 6.1 Stream B.4 `ScheduledRecycleScheduler` HostedService (task #137), Phase 6.1 Stream A analyzer (task #135 — Roslyn analyzer asserting every capability surface routes through `CapabilityInvoker`).
|
||||
- **Multi-host dispatch** — Phase 6.1 Stream A follow-up (task #135). Currently every driver gets a single pipeline keyed on `driver.DriverInstanceId`; multi-host drivers (Modbus with N PLCs) need per-PLC host resolution so failing PLCs trip per-PLC breakers without poisoning siblings. Decision #144 requires this but we haven't wired it yet.
|
||||
|
||||
## Running the release-readiness check
|
||||
|
||||
```bash
|
||||
pwsh ./scripts/compliance/phase-6-all.ps1
|
||||
```
|
||||
|
||||
This meta-runner invokes each `phase-6-N-compliance.ps1` script in sequence and reports an aggregate PASS/FAIL. It is the single-command verification that what we claim is shipped still compiles + tests pass + the plan-level invariants are still satisfied.
|
||||
|
||||
Exit 0 = every phase passes its compliance checks + no test-count regression.
|
||||
|
||||
## Release-readiness exit criteria
|
||||
|
||||
v2 GA requires all of the following:
|
||||
|
||||
- [ ] All four Phase 6.N compliance scripts exit 0.
|
||||
- [ ] `dotnet test ZB.MOM.WW.OtOpcUa.slnx` passes with ≤ 1 known-flake failure.
|
||||
- [ ] Release blockers listed above all closed (or consciously deferred to v2.1 with a written decision).
|
||||
- [ ] Production deployment checklist (separate doc) signed off by Fleet Admin.
|
||||
- [ ] At least one end-to-end integration run against the live Galaxy on the dev box succeeds.
|
||||
- [ ] OPC UA conformance test (CTT or UA Compliance Test Tool) passes against the live endpoint.
|
||||
- [ ] Non-transparent redundancy cutover validated with at least one production client (Ignition 8.3 recommended — see decision #85).
|
||||
|
||||
## Change log
|
||||
|
||||
- **2026-04-19** — Release blocker #3 **closed** (PRs #98–99). Phase 6.3 Streams A + C core shipped: `ClusterTopologyLoader` + `RedundancyCoordinator` + `RedundancyStatePublisher` + `PeerReachabilityTracker`. Code-path release blockers all closed; remaining Phase 6.3 surfaces (peer-probe HostedServices, OPC UA variable-node binding, sp_PublishGeneration lease wrap, client interop matrix) are hardening follow-ups.
|
||||
- **2026-04-19** — Release blocker #2 **closed** (PR #96). `SealedBootstrap` consumes `ResilientConfigReader` + `GenerationSealedCache` + `StaleConfigFlag`; `/healthz` now surfaces the stale flag. Remaining follow-ups (periodic poller + richer snapshot payload) downgraded to hardening.
|
||||
- **2026-04-19** — Release blocker #1 **closed** (PR #94). `AuthorizationGate` wired into `DriverNodeManager` Read / Write / HistoryRead dispatch. Remaining Stream C surfaces (Browse / Subscribe / Alarm / Call + finer-grained scope resolution) downgraded to hardening follow-ups — no longer release-blocking.
|
||||
- **2026-04-19** — Phase 6.4 data layer merged (PRs #91–92). Phase 6 core complete. Capstone doc created.
|
||||
- **2026-04-19** — Phase 6.3 core merged (PRs #89–90). `ServiceLevelCalculator` + `RecoveryStateManager` + `ApplyLeaseRegistry` land as pure logic; coordinator / UA-node wiring / Admin UI / interop deferred.
|
||||
- **2026-04-19** — Phase 6.2 core merged (PRs #84–88). `AuthorizationGate` + `TriePermissionEvaluator` + `LdapGroupRoleMapping` land; dispatch wiring + Admin UI deferred.
|
||||
- **2026-04-19** — Phase 6.1 shipped (PRs #78–83). Polly resilience + Tier A/B/C stability + health endpoints + LiteDB generation-sealed cache + Admin `/hosts` data layer all live.
|
||||
@@ -1,82 +1,95 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Phase 6.4 exit-gate compliance check — stub. Each `Assert-*` either passes
|
||||
(Write-Host green) or throws. Non-zero exit = fail.
|
||||
Phase 6.4 exit-gate compliance check. Each check either passes or records a
|
||||
failure; non-zero exit = fail.
|
||||
|
||||
.DESCRIPTION
|
||||
Validates Phase 6.4 (Admin UI completion) completion. Checks enumerated in
|
||||
Validates Phase 6.4 (Admin UI completion) progress. Checks enumerated in
|
||||
`docs/v2/implementation/phase-6-4-admin-ui-completion.md`
|
||||
§"Compliance Checks (run at exit gate)".
|
||||
|
||||
Current status: SCAFFOLD. Every check writes a TODO line and does NOT throw.
|
||||
Each implementation task in Phase 6.4 is responsible for replacing its TODO
|
||||
with a real check before closing that task.
|
||||
|
||||
.NOTES
|
||||
Usage: pwsh ./scripts/compliance/phase-6-4-compliance.ps1
|
||||
Exit: 0 = all checks passed (or are still TODO); non-zero = explicit fail
|
||||
Exit: 0 = all checks passed; non-zero = one or more FAILs
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$script:failures = 0
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
|
||||
function Assert-Todo {
|
||||
param([string]$Check, [string]$ImplementationTask)
|
||||
Write-Host " [TODO] $Check (implement during $ImplementationTask)" -ForegroundColor Yellow
|
||||
function Assert-Pass { param([string]$C) Write-Host " [PASS] $C" -ForegroundColor Green }
|
||||
function Assert-Fail { param([string]$C, [string]$R) Write-Host " [FAIL] $C - $R" -ForegroundColor Red; $script:failures++ }
|
||||
function Assert-Deferred { param([string]$C, [string]$P) Write-Host " [DEFERRED] $C (follow-up: $P)" -ForegroundColor Yellow }
|
||||
|
||||
function Assert-FileExists {
|
||||
param([string]$C, [string]$P)
|
||||
if (Test-Path (Join-Path $repoRoot $P)) { Assert-Pass "$C ($P)" }
|
||||
else { Assert-Fail $C "missing file: $P" }
|
||||
}
|
||||
|
||||
function Assert-Pass {
|
||||
param([string]$Check)
|
||||
Write-Host " [PASS] $Check" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Assert-Fail {
|
||||
param([string]$Check, [string]$Reason)
|
||||
Write-Host " [FAIL] $Check — $Reason" -ForegroundColor Red
|
||||
$script:failures++
|
||||
function Assert-TextFound {
|
||||
param([string]$C, [string]$Pat, [string[]]$Paths)
|
||||
foreach ($p in $Paths) {
|
||||
$full = Join-Path $repoRoot $p
|
||||
if (-not (Test-Path $full)) { continue }
|
||||
if (Select-String -Path $full -Pattern $Pat -Quiet) {
|
||||
Assert-Pass "$C (matched in $p)"
|
||||
return
|
||||
}
|
||||
}
|
||||
Assert-Fail $C "pattern '$Pat' not found in any of: $($Paths -join ', ')"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Phase 6.4 compliance — Admin UI completion ===" -ForegroundColor Cyan
|
||||
Write-Host "=== Phase 6.4 compliance - Admin UI completion ===" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "Stream A — UNS drag/move + impact preview"
|
||||
Assert-Todo "UNS drag/move — drag line across areas; modal shows correct impacted-equipment + tag counts" "Stream A.2"
|
||||
Assert-Todo "Concurrent-edit safety — session B saves draft mid-preview; session A Confirm returns 409" "Stream A.3 (DraftRevisionToken)"
|
||||
Assert-Todo "Cross-cluster drop disabled — actionable toast points to Export/Import" "Stream A.2"
|
||||
Assert-Todo "1000-node tree — drag-enter feedback < 100 ms" "Stream A.4"
|
||||
Write-Host "Stream A data layer - UnsImpactAnalyzer"
|
||||
Assert-FileExists "UnsImpactAnalyzer present" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs"
|
||||
Assert-TextFound "DraftRevisionToken present" "record DraftRevisionToken" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs")
|
||||
Assert-TextFound "Cross-cluster move rejected per decision #82" "CrossClusterMoveRejectedException" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs")
|
||||
Assert-TextFound "LineMove + AreaRename + LineMerge covered" "UnsMoveKind\.LineMerge" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs")
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Stream B — CSV import + staged-import + 5-identifier search"
|
||||
Assert-Todo "CSV header version — file missing '# OtOpcUaCsv v1' rejected pre-parse" "Stream B.1"
|
||||
Assert-Todo "CSV canonical identifier set — columns match decision #117 exactly" "Stream B.1"
|
||||
Assert-Todo "Staged-import atomicity — 10k-row FinaliseImportBatch < 30 s; user-scoped visibility; DropImportBatch rollback" "Stream B.3"
|
||||
Assert-Todo "Concurrent import + external reservation — finalize retries with conflict handling; no corruption" "Stream B.3"
|
||||
Assert-Todo "5-identifier search ranking — exact > prefix; published > draft for equal scores" "Stream B.4"
|
||||
Write-Host "Stream B data layer - EquipmentCsvImporter"
|
||||
Assert-FileExists "EquipmentCsvImporter present" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs"
|
||||
Assert-TextFound "CSV header version marker '# OtOpcUaCsv v1'" "OtOpcUaCsv v1" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs")
|
||||
Assert-TextFound "Required columns match decision #117" "ZTag.+MachineCode.+SAPID.+EquipmentId.+EquipmentUuid" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs")
|
||||
Assert-TextFound "Optional columns match decision #139 (Manufacturer)" "Manufacturer" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs")
|
||||
Assert-TextFound "Optional columns include DeviceManualUri" "DeviceManualUri" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs")
|
||||
Assert-TextFound "Rejects duplicate ZTag within file" "Duplicate ZTag" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs")
|
||||
Assert-TextFound "Rejects unknown column" "unknown column" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs")
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Stream C — DiffViewer sections"
|
||||
Assert-Todo "Diff viewer section caps — 2000-row subtree-rename summary-only; 'Load full diff' paginates" "Stream C.2"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Stream D — Identification (OPC 40010)"
|
||||
Assert-Todo "OPC 40010 field list match — rendered fields match decision #139 exactly; no extras" "Stream D.1"
|
||||
Assert-Todo "OPC 40010 exposure — Identification sub-folder shows when non-null; absent when all null" "Stream D.3"
|
||||
Assert-Todo "ACL inheritance for Identification — Equipment-grant reads; no-grant denies both" "Stream D.4"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Visual compliance"
|
||||
Assert-Todo "Visual parity reviewer — FleetAdmin signoff vs admin-ui.md §Visual-Design; screenshot set checked in under docs/v2/visual-compliance/phase-6-4/" "Visual review"
|
||||
Write-Host "Deferred surfaces"
|
||||
Assert-Deferred "Stream A UI - UnsTab MudBlazor drag/drop + 409 modal + Playwright" "task #153"
|
||||
Assert-Deferred "Stream B follow-up - EquipmentImportBatch staging + FinaliseImportBatch + CSV import UI" "task #155"
|
||||
Assert-Deferred "Stream C - DiffViewer refactor + 6 section plugins + 1000-row cap" "task #156"
|
||||
Assert-Deferred "Stream D - IdentificationFields.razor + DriverNodeManager OPC 40010 sub-folder" "task #157"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Cross-cutting"
|
||||
Assert-Todo "Full solution dotnet test passes; no test-count regression vs pre-Phase-6.4 baseline" "Final exit-gate"
|
||||
Write-Host " Running full solution test suite..." -ForegroundColor DarkGray
|
||||
$prevPref = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$testOutput = & dotnet test (Join-Path $repoRoot 'ZB.MOM.WW.OtOpcUa.slnx') --nologo 2>&1
|
||||
$ErrorActionPreference = $prevPref
|
||||
$passLine = $testOutput | Select-String 'Passed:\s+(\d+)' -AllMatches
|
||||
$failLine = $testOutput | Select-String 'Failed:\s+(\d+)' -AllMatches
|
||||
$passCount = 0; foreach ($m in $passLine.Matches) { $passCount += [int]$m.Groups[1].Value }
|
||||
$failCount = 0; foreach ($m in $failLine.Matches) { $failCount += [int]$m.Groups[1].Value }
|
||||
$baseline = 1137
|
||||
if ($passCount -ge $baseline) { Assert-Pass "No test-count regression ($passCount >= $baseline pre-Phase-6.4 baseline)" }
|
||||
else { Assert-Fail "Test-count regression" "passed $passCount < baseline $baseline" }
|
||||
|
||||
if ($failCount -le 1) { Assert-Pass "No new failing tests (pre-existing CLI flake tolerated)" }
|
||||
else { Assert-Fail "New failing tests" "$failCount failures > 1 tolerated" }
|
||||
|
||||
Write-Host ""
|
||||
if ($script:failures -eq 0) {
|
||||
Write-Host "Phase 6.4 compliance: scaffold-mode PASS (all checks TODO)" -ForegroundColor Green
|
||||
Write-Host "Phase 6.4 compliance: PASS" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
Write-Host "Phase 6.4 compliance: $script:failures FAIL(s)" -ForegroundColor Red
|
||||
|
||||
77
scripts/compliance/phase-6-all.ps1
Normal file
77
scripts/compliance/phase-6-all.ps1
Normal file
@@ -0,0 +1,77 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Meta-runner that invokes every per-phase Phase 6.x compliance script and
|
||||
reports an aggregate verdict.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs phase-6-1-compliance.ps1, phase-6-2, phase-6-3, phase-6-4 in sequence.
|
||||
Each sub-script returns its own exit code; this wrapper aggregates them.
|
||||
Useful before a v2 release tag + as the `dotnet test` companion in CI.
|
||||
|
||||
.NOTES
|
||||
Usage: pwsh ./scripts/compliance/phase-6-all.ps1
|
||||
Exit: 0 = every phase passed; 1 = one or more phases failed
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$phases = @(
|
||||
@{ Name = 'Phase 6.1 - Resilience & Observability'; Script = 'phase-6-1-compliance.ps1' },
|
||||
@{ Name = 'Phase 6.2 - Authorization runtime'; Script = 'phase-6-2-compliance.ps1' },
|
||||
@{ Name = 'Phase 6.3 - Redundancy runtime'; Script = 'phase-6-3-compliance.ps1' },
|
||||
@{ Name = 'Phase 6.4 - Admin UI completion'; Script = 'phase-6-4-compliance.ps1' }
|
||||
)
|
||||
|
||||
$results = @()
|
||||
$startedAt = Get-Date
|
||||
|
||||
foreach ($phase in $phases) {
|
||||
Write-Host ""
|
||||
Write-Host ""
|
||||
Write-Host "=============================================================" -ForegroundColor DarkGray
|
||||
Write-Host ("Running {0}" -f $phase.Name) -ForegroundColor Cyan
|
||||
Write-Host "=============================================================" -ForegroundColor DarkGray
|
||||
|
||||
$scriptPath = Join-Path $PSScriptRoot $phase.Script
|
||||
if (-not (Test-Path $scriptPath)) {
|
||||
Write-Host (" [MISSING] {0}" -f $phase.Script) -ForegroundColor Red
|
||||
$results += @{ Name = $phase.Name; Exit = 2 }
|
||||
continue
|
||||
}
|
||||
|
||||
# Invoke each sub-script in its own powershell.exe process so its local
|
||||
# $ErrorActionPreference + exit-code semantics can't interfere with the meta-runner's
|
||||
# state. Slower (one process spawn per phase) but makes aggregate PASS/FAIL match
|
||||
# standalone runs exactly.
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $scriptPath
|
||||
$exitCode = $LASTEXITCODE
|
||||
$results += @{ Name = $phase.Name; Exit = $exitCode }
|
||||
}
|
||||
|
||||
$elapsed = (Get-Date) - $startedAt
|
||||
|
||||
Write-Host ""
|
||||
Write-Host ""
|
||||
Write-Host "=============================================================" -ForegroundColor DarkGray
|
||||
Write-Host "Phase 6 compliance aggregate" -ForegroundColor Cyan
|
||||
Write-Host "=============================================================" -ForegroundColor DarkGray
|
||||
|
||||
$totalFailures = 0
|
||||
foreach ($r in $results) {
|
||||
$colour = if ($r.Exit -eq 0) { 'Green' } else { 'Red' }
|
||||
$tag = if ($r.Exit -eq 0) { 'PASS' } else { "FAIL (exit=$($r.Exit))" }
|
||||
Write-Host (" [{0}] {1}" -f $tag, $r.Name) -ForegroundColor $colour
|
||||
if ($r.Exit -ne 0) { $totalFailures++ }
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host ("Elapsed: {0:N1} s" -f $elapsed.TotalSeconds) -ForegroundColor DarkGray
|
||||
|
||||
if ($totalFailures -eq 0) {
|
||||
Write-Host "Phase 6 aggregate: PASS" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
Write-Host ("Phase 6 aggregate: {0} phase(s) FAILED" -f $totalFailures) -ForegroundColor Red
|
||||
exit 1
|
||||
@@ -10,6 +10,7 @@
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/certificates">Certificates</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/role-grants">Role grants</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-5">
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
|
||||
@inject NodeAclService AclSvc
|
||||
@inject PermissionProbeService ProbeSvc
|
||||
@inject NavigationManager Nav
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>Access-control grants</h4>
|
||||
@@ -29,6 +35,95 @@ else
|
||||
</table>
|
||||
}
|
||||
|
||||
@* Probe-this-permission — task #196 slice 1 *@
|
||||
<div class="card mt-4 mb-3">
|
||||
<div class="card-header">
|
||||
<strong>Probe this permission</strong>
|
||||
<span class="small text-muted ms-2">
|
||||
Ask the trie "if LDAP group X asks for permission Y on node Z, would it be granted?" —
|
||||
answers the same way the live server does at request time.
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">LDAP group</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeGroup" placeholder="cn=fleet-admin,…"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Namespace</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeNamespaceId" placeholder="ns-1"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">UnsArea</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeUnsAreaId"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">UnsLine</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeUnsLineId"/>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Equipment</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeEquipmentId"/>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Tag</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeTagId"/>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Permission</label>
|
||||
<select class="form-select form-select-sm" @bind="_probePermission">
|
||||
@foreach (var p in Enum.GetValues<NodePermissions>())
|
||||
{
|
||||
if (p == NodePermissions.None) continue;
|
||||
<option value="@p">@p</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="RunProbeAsync" disabled="@_probing">Probe</button>
|
||||
@if (_probeResult is not null)
|
||||
{
|
||||
<span class="ms-3">
|
||||
@if (_probeResult.Granted)
|
||||
{
|
||||
<span class="badge bg-success">Granted</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">Denied</span>
|
||||
}
|
||||
<span class="small ms-2">
|
||||
Required <code>@_probeResult.Required</code>,
|
||||
Effective <code>@_probeResult.Effective</code>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (_probeResult is not null && _probeResult.Matches.Count > 0)
|
||||
{
|
||||
<table class="table table-sm mt-3 mb-0">
|
||||
<thead><tr><th>LDAP group matched</th><th>Level</th><th>Flags contributed</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var m in _probeResult.Matches)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@m.LdapGroup</code></td>
|
||||
<td>@m.Scope</td>
|
||||
<td><code>@m.PermissionFlags</code></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
else if (_probeResult is not null)
|
||||
{
|
||||
<div class="mt-2 small text-muted">No matching grants for this (group, scope) — effective permission is <code>None</code>.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card">
|
||||
@@ -80,6 +175,64 @@ else
|
||||
private string _preset = "Read";
|
||||
private string? _error;
|
||||
|
||||
// Probe-this-permission state
|
||||
private string _probeGroup = string.Empty;
|
||||
private string _probeNamespaceId = string.Empty;
|
||||
private string _probeUnsAreaId = string.Empty;
|
||||
private string _probeUnsLineId = string.Empty;
|
||||
private string _probeEquipmentId = string.Empty;
|
||||
private string _probeTagId = string.Empty;
|
||||
private NodePermissions _probePermission = NodePermissions.Read;
|
||||
private PermissionProbeResult? _probeResult;
|
||||
private bool _probing;
|
||||
|
||||
private async Task RunProbeAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_probeGroup)) { _probeResult = null; return; }
|
||||
_probing = true;
|
||||
try
|
||||
{
|
||||
var scope = new NodeScope
|
||||
{
|
||||
ClusterId = ClusterId,
|
||||
NamespaceId = NullIfBlank(_probeNamespaceId),
|
||||
UnsAreaId = NullIfBlank(_probeUnsAreaId),
|
||||
UnsLineId = NullIfBlank(_probeUnsLineId),
|
||||
EquipmentId = NullIfBlank(_probeEquipmentId),
|
||||
TagId = NullIfBlank(_probeTagId),
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
_probeResult = await ProbeSvc.ProbeAsync(GenerationId, _probeGroup.Trim(), scope, _probePermission, CancellationToken.None);
|
||||
}
|
||||
finally { _probing = false; }
|
||||
}
|
||||
|
||||
private static string? NullIfBlank(string s) => string.IsNullOrWhiteSpace(s) ? null : s;
|
||||
|
||||
private HubConnection? _hub;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || _hub is not null) return;
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
_hub.On<NodeAclChangedMessage>("NodeAclChanged", async msg =>
|
||||
{
|
||||
if (msg.ClusterId != ClusterId || msg.GenerationId != GenerationId) return;
|
||||
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
});
|
||||
await _hub.StartAsync();
|
||||
await _hub.SendAsync("SubscribeCluster", ClusterId);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hub is not null) { await _hub.DisposeAsync(); _hub = null; }
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync() =>
|
||||
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ else
|
||||
<li class="nav-item"><button class="nav-link @Tab("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("redundancy")" @onclick='() => _tab = "redundancy"'>Redundancy</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("audit")" @onclick='() => _tab = "audit"'>Audit</button></li>
|
||||
</ul>
|
||||
|
||||
@@ -92,6 +93,10 @@ else
|
||||
{
|
||||
<AclsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "redundancy")
|
||||
{
|
||||
<RedundancyTab ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "audit")
|
||||
{
|
||||
<AuditTab ClusterId="@ClusterId"/>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
|
||||
@* Per-section diff renderer — the base used by DiffViewer for every known TableName. Caps
|
||||
output at RowCap rows so a pathological draft (e.g. 20k tags churned) can't freeze the
|
||||
Blazor render; overflow banner tells operator how many rows were hidden. *@
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>@Title</strong>
|
||||
<small class="text-muted ms-2">@Description</small>
|
||||
</div>
|
||||
<div>
|
||||
@if (_added > 0) { <span class="badge bg-success me-1">+@_added</span> }
|
||||
@if (_removed > 0) { <span class="badge bg-danger me-1">−@_removed</span> }
|
||||
@if (_modified > 0) { <span class="badge bg-warning text-dark me-1">~@_modified</span> }
|
||||
@if (_total == 0) { <span class="badge bg-secondary">no changes</span> }
|
||||
</div>
|
||||
</div>
|
||||
@if (_total == 0)
|
||||
{
|
||||
<div class="card-body text-muted small">No changes in this section.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_total > RowCap)
|
||||
{
|
||||
<div class="alert alert-warning mb-0 small rounded-0">
|
||||
Showing the first @RowCap of @_total rows — cap protects the browser from megabyte-class
|
||||
diffs. Inspect the remainder via the SQL <code>sp_ComputeGenerationDiff</code> directly.
|
||||
</div>
|
||||
}
|
||||
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>LogicalId</th><th style="width: 120px;">Change</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _visibleRows)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.LogicalId</code></td>
|
||||
<td>
|
||||
@switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": <span class="badge bg-success">@r.ChangeKind</span> break;
|
||||
case "Removed": <span class="badge bg-danger">@r.ChangeKind</span> break;
|
||||
case "Modified": <span class="badge bg-warning text-dark">@r.ChangeKind</span> break;
|
||||
default: <span class="badge bg-secondary">@r.ChangeKind</span> break;
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Default row-cap per section — matches task #156's acceptance criterion.</summary>
|
||||
public const int DefaultRowCap = 1000;
|
||||
|
||||
[Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
|
||||
[Parameter] public string Description { get; set; } = string.Empty;
|
||||
[Parameter, EditorRequired] public IReadOnlyList<DiffRow> Rows { get; set; } = [];
|
||||
[Parameter] public int RowCap { get; set; } = DefaultRowCap;
|
||||
|
||||
private int _total;
|
||||
private int _added;
|
||||
private int _removed;
|
||||
private int _modified;
|
||||
private List<DiffRow> _visibleRows = [];
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_total = Rows.Count;
|
||||
_added = 0; _removed = 0; _modified = 0;
|
||||
foreach (var r in Rows)
|
||||
{
|
||||
switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": _added++; break;
|
||||
case "Removed": _removed++; break;
|
||||
case "Modified": _modified++; break;
|
||||
}
|
||||
}
|
||||
_visibleRows = _total > RowCap ? Rows.Take(RowCap).ToList() : Rows.ToList();
|
||||
}
|
||||
}
|
||||
@@ -28,36 +28,44 @@ else if (_rows.Count == 0)
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-hover table-sm">
|
||||
<thead><tr><th>Table</th><th>LogicalId</th><th>ChangeKind</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td>@r.TableName</td>
|
||||
<td><code>@r.LogicalId</code></td>
|
||||
<td>
|
||||
@switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": <span class="badge bg-success">@r.ChangeKind</span> break;
|
||||
case "Removed": <span class="badge bg-danger">@r.ChangeKind</span> break;
|
||||
case "Modified": <span class="badge bg-warning text-dark">@r.ChangeKind</span> break;
|
||||
default: <span class="badge bg-secondary">@r.ChangeKind</span> break;
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="small text-muted mb-3">
|
||||
@_rows.Count row@(_rows.Count == 1 ? "" : "s") across @_sectionsWithChanges of @Sections.Count sections.
|
||||
Each section is capped at @DiffSection.DefaultRowCap rows to keep the browser responsive on pathological drafts.
|
||||
</p>
|
||||
|
||||
@foreach (var sec in Sections)
|
||||
{
|
||||
<DiffSection Title="@sec.Title"
|
||||
Description="@sec.Description"
|
||||
Rows="@RowsFor(sec.TableName)"/>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered section definitions — each maps a <c>TableName</c> emitted by
|
||||
/// <c>sp_ComputeGenerationDiff</c> to a human label + description. The proc currently
|
||||
/// emits Namespace/DriverInstance/Equipment/Tag; UnsLine + NodeAcl entries render as
|
||||
/// empty "no changes" cards until the proc is extended (tracked in tasks #196 + #156
|
||||
/// follow-up). Six sections total matches the task #156 target.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyList<SectionDef> Sections = new[]
|
||||
{
|
||||
new SectionDef("Namespace", "Namespaces", "OPC UA namespace URIs + enablement"),
|
||||
new SectionDef("DriverInstance", "Driver instances","Per-cluster driver configuration rows"),
|
||||
new SectionDef("Equipment", "Equipment", "UNS level-5 rows + identification fields"),
|
||||
new SectionDef("Tag", "Tags", "Per-device tag definitions + poll-group binding"),
|
||||
new SectionDef("UnsLine", "UNS structure", "Site / Area / Line hierarchy (proc-extension pending)"),
|
||||
new SectionDef("NodeAcl", "ACLs", "LDAP-group → node-scope permission grants (logical id = LdapGroup|ScopeKind|ScopeId)"),
|
||||
};
|
||||
|
||||
private List<DiffRow>? _rows;
|
||||
private string _fromLabel = "(empty)";
|
||||
private string? _error;
|
||||
private int _sectionsWithChanges;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
@@ -67,7 +75,13 @@ else
|
||||
var from = all.FirstOrDefault(g => g.Status == GenerationStatus.Published);
|
||||
_fromLabel = from is null ? "(empty)" : $"gen {from.GenerationId}";
|
||||
_rows = await GenerationSvc.ComputeDiffAsync(from?.GenerationId ?? 0, GenerationId, CancellationToken.None);
|
||||
_sectionsWithChanges = Sections.Count(s => _rows.Any(r => r.TableName == s.TableName));
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private IReadOnlyList<DiffRow> RowsFor(string tableName) =>
|
||||
_rows?.Where(r => r.TableName == tableName).ToList() ?? [];
|
||||
|
||||
private sealed record SectionDef(string TableName, string Title, string Description);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
@if (_tab == "equipment") { <EquipmentTab GenerationId="@GenerationId"/> }
|
||||
@if (_tab == "equipment") { <EquipmentTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "uns") { <UnsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
|
||||
@inject EquipmentService EquipmentSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>Equipment (draft gen @GenerationId)</h4>
|
||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add equipment</button>
|
||||
<div>
|
||||
<button class="btn btn-outline-primary btn-sm me-2" @onclick="GoImport">Import CSV…</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add equipment</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_equipment is null)
|
||||
@@ -36,7 +40,10 @@ else if (_equipment.Count > 0)
|
||||
<td>@e.SAPID</td>
|
||||
<td>@e.Manufacturer / @e.Model</td>
|
||||
<td>@e.SerialNumber</td>
|
||||
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(e.EquipmentRowId)">Remove</button></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => StartEdit(e)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(e.EquipmentRowId)">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -47,8 +54,8 @@ else if (_equipment.Count > 0)
|
||||
{
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h5>New equipment</h5>
|
||||
<EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="new-equipment">
|
||||
<h5>@(_editMode ? "Edit equipment" : "New equipment")</h5>
|
||||
<EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="equipment-form">
|
||||
<DataAnnotationsValidator/>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
@@ -78,24 +85,13 @@ else if (_equipment.Count > 0)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="mt-4">OPC 40010 Identification</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4"><label class="form-label">Manufacturer</label><InputText @bind-Value="_draft.Manufacturer" class="form-control"/></div>
|
||||
<div class="col-md-4"><label class="form-label">Model</label><InputText @bind-Value="_draft.Model" class="form-control"/></div>
|
||||
<div class="col-md-4"><label class="form-label">Serial number</label><InputText @bind-Value="_draft.SerialNumber" class="form-control"/></div>
|
||||
<div class="col-md-4"><label class="form-label">Hardware rev</label><InputText @bind-Value="_draft.HardwareRevision" class="form-control"/></div>
|
||||
<div class="col-md-4"><label class="form-label">Software rev</label><InputText @bind-Value="_draft.SoftwareRevision" class="form-control"/></div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Year of construction</label>
|
||||
<InputNumber @bind-Value="_draft.YearOfConstruction" class="form-control"/>
|
||||
</div>
|
||||
</div>
|
||||
<IdentificationFields Equipment="_draft"/>
|
||||
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm ms-2" @onclick="Cancel">Cancel</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
@@ -104,8 +100,12 @@ else if (_equipment.Count > 0)
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private void GoImport() => Nav.NavigateTo($"/clusters/{ClusterId}/draft/{GenerationId}/import-equipment");
|
||||
private List<Equipment>? _equipment;
|
||||
private bool _showForm;
|
||||
private bool _editMode;
|
||||
private Equipment _draft = NewBlankDraft();
|
||||
private string? _error;
|
||||
|
||||
@@ -125,20 +125,68 @@ else if (_equipment.Count > 0)
|
||||
private void StartAdd()
|
||||
{
|
||||
_draft = NewBlankDraft();
|
||||
_editMode = false;
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void StartEdit(Equipment row)
|
||||
{
|
||||
// Shallow-clone so Cancel doesn't mutate the list-displayed row with in-flight form edits.
|
||||
_draft = new Equipment
|
||||
{
|
||||
EquipmentRowId = row.EquipmentRowId,
|
||||
GenerationId = row.GenerationId,
|
||||
EquipmentId = row.EquipmentId,
|
||||
EquipmentUuid = row.EquipmentUuid,
|
||||
DriverInstanceId = row.DriverInstanceId,
|
||||
DeviceId = row.DeviceId,
|
||||
UnsLineId = row.UnsLineId,
|
||||
Name = row.Name,
|
||||
MachineCode = row.MachineCode,
|
||||
ZTag = row.ZTag,
|
||||
SAPID = row.SAPID,
|
||||
Manufacturer = row.Manufacturer,
|
||||
Model = row.Model,
|
||||
SerialNumber = row.SerialNumber,
|
||||
HardwareRevision = row.HardwareRevision,
|
||||
SoftwareRevision = row.SoftwareRevision,
|
||||
YearOfConstruction = row.YearOfConstruction,
|
||||
AssetLocation = row.AssetLocation,
|
||||
ManufacturerUri = row.ManufacturerUri,
|
||||
DeviceManualUri = row.DeviceManualUri,
|
||||
EquipmentClassRef = row.EquipmentClassRef,
|
||||
Enabled = row.Enabled,
|
||||
};
|
||||
_editMode = true;
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
_showForm = false;
|
||||
_editMode = false;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
_draft.EquipmentUuid = Guid.NewGuid();
|
||||
_draft.EquipmentId = DraftValidator.DeriveEquipmentId(_draft.EquipmentUuid);
|
||||
_draft.GenerationId = GenerationId;
|
||||
try
|
||||
{
|
||||
await EquipmentSvc.CreateAsync(GenerationId, _draft, CancellationToken.None);
|
||||
if (_editMode)
|
||||
{
|
||||
await EquipmentSvc.UpdateAsync(_draft, CancellationToken.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
_draft.EquipmentUuid = Guid.NewGuid();
|
||||
_draft.EquipmentId = DraftValidator.DeriveEquipmentId(_draft.EquipmentUuid);
|
||||
_draft.GenerationId = GenerationId;
|
||||
await EquipmentSvc.CreateAsync(GenerationId, _draft, CancellationToken.None);
|
||||
}
|
||||
_showForm = false;
|
||||
_editMode = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
|
||||
@* Reusable OPC 40010 Machinery Identification editor. Binds to an Equipment row and renders the
|
||||
nine decision #139 fields in a consistent 3-column Bootstrap grid. Used by EquipmentTab's
|
||||
create + edit forms so the same UI renders regardless of which flow opened it. *@
|
||||
|
||||
<h6 class="mt-4">OPC 40010 Identification</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Manufacturer</label>
|
||||
<InputText @bind-Value="Equipment!.Manufacturer" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Model</label>
|
||||
<InputText @bind-Value="Equipment!.Model" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Serial number</label>
|
||||
<InputText @bind-Value="Equipment!.SerialNumber" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Hardware rev</label>
|
||||
<InputText @bind-Value="Equipment!.HardwareRevision" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Software rev</label>
|
||||
<InputText @bind-Value="Equipment!.SoftwareRevision" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Year of construction</label>
|
||||
<InputNumber @bind-Value="Equipment!.YearOfConstruction" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Asset location</label>
|
||||
<InputText @bind-Value="Equipment!.AssetLocation" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Manufacturer URI</label>
|
||||
<InputText @bind-Value="Equipment!.ManufacturerUri" class="form-control" placeholder="https://…"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Device manual URI</label>
|
||||
<InputText @bind-Value="Equipment!.DeviceManualUri" class="form-control" placeholder="https://…"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public Equipment? Equipment { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
@page "/clusters/{ClusterId}/draft/{GenerationId:long}/import-equipment"
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject DriverInstanceService DriverSvc
|
||||
@inject UnsService UnsSvc
|
||||
@inject EquipmentImportBatchService BatchSvc
|
||||
@inject NavigationManager Nav
|
||||
@inject AuthenticationStateProvider AuthProvider
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">Equipment CSV import</h1>
|
||||
<small class="text-muted">Cluster <code>@ClusterId</code> · draft generation @GenerationId</small>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to draft</a>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info small mb-3">
|
||||
Accepts <code>@EquipmentCsvImporter.VersionMarker</code>-headered CSV per Stream B.3.
|
||||
Required columns: @string.Join(", ", EquipmentCsvImporter.RequiredColumns).
|
||||
Optional columns cover the OPC 40010 Identification fields. Paste the file contents
|
||||
or upload directly — the parser runs client-stream-side and shows a row-level preview
|
||||
before anything lands in the draft. ZTag + SAPID uniqueness across the fleet is NOT
|
||||
enforced here yet (see task #197); for now the finalise may fail at commit time if a
|
||||
reservation conflict exists.
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Target driver instance (for every accepted row)</label>
|
||||
<select class="form-select" @bind="_driverInstanceId">
|
||||
<option value="">-- select driver --</option>
|
||||
@if (_drivers is not null)
|
||||
{
|
||||
@foreach (var d in _drivers) { <option value="@d.DriverInstanceId">@d.DriverInstanceId</option> }
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Target UNS line (for every accepted row)</label>
|
||||
<select class="form-select" @bind="_unsLineId">
|
||||
<option value="">-- select line --</option>
|
||||
@if (_unsLines is not null)
|
||||
{
|
||||
@foreach (var l in _unsLines) { <option value="@l.UnsLineId">@l.UnsLineId — @l.Name</option> }
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 pt-4">
|
||||
<InputFile OnChange="HandleFileAsync" class="form-control form-control-sm" accept=".csv,.txt"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="form-label">CSV content (paste or uploaded)</label>
|
||||
<textarea class="form-control font-monospace" rows="8" @bind="_csvText"
|
||||
placeholder="# OtOpcUaCsv v1 ZTag,MachineCode,SAPID,EquipmentId,…"/>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="ParseAsync" disabled="@_busy">Parse</button>
|
||||
<button class="btn btn-sm btn-primary ms-2" @onclick="StageAndFinaliseAsync"
|
||||
disabled="@(_parseResult is null || _parseResult.AcceptedRows.Count == 0 || string.IsNullOrWhiteSpace(_driverInstanceId) || string.IsNullOrWhiteSpace(_unsLineId) || _busy)">
|
||||
Stage + Finalise
|
||||
</button>
|
||||
@if (_parseError is not null) { <span class="alert alert-danger ms-3 py-1 px-2 small">@_parseError</span> }
|
||||
@if (_result is not null) { <span class="alert alert-success ms-3 py-1 px-2 small">@_result</span> }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_parseResult is not null)
|
||||
{
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-success text-white">
|
||||
Accepted (@_parseResult.AcceptedRows.Count)
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
@if (_parseResult.AcceptedRows.Count == 0)
|
||||
{
|
||||
<p class="text-muted p-3 mb-0">No accepted rows.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead>
|
||||
<tr><th>ZTag</th><th>Machine</th><th>Name</th><th>Line</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _parseResult.AcceptedRows)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.ZTag</code></td>
|
||||
<td>@r.MachineCode</td>
|
||||
<td>@r.Name</td>
|
||||
<td>@r.UnsLineName</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-danger text-white">
|
||||
Rejected (@_parseResult.RejectedRows.Count)
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
@if (_parseResult.RejectedRows.Count == 0)
|
||||
{
|
||||
<p class="text-muted p-3 mb-0">No rejections.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead><tr><th>Line</th><th>Reason</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var e in _parseResult.RejectedRows)
|
||||
{
|
||||
<tr>
|
||||
<td>@e.LineNumber</td>
|
||||
<td class="small">@e.Reason</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
|
||||
private List<DriverInstance>? _drivers;
|
||||
private List<UnsLine>? _unsLines;
|
||||
private string _driverInstanceId = string.Empty;
|
||||
private string _unsLineId = string.Empty;
|
||||
private string _csvText = string.Empty;
|
||||
private EquipmentCsvParseResult? _parseResult;
|
||||
private string? _parseError;
|
||||
private string? _result;
|
||||
private bool _busy;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_unsLines = await UnsSvc.ListLinesAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task HandleFileAsync(InputFileChangeEventArgs e)
|
||||
{
|
||||
// 5 MiB cap — refuses pathological uploads that would OOM the server.
|
||||
using var stream = e.File.OpenReadStream(maxAllowedSize: 5 * 1024 * 1024);
|
||||
using var reader = new StreamReader(stream);
|
||||
_csvText = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
private void ParseAsync()
|
||||
{
|
||||
_parseError = null;
|
||||
_parseResult = null;
|
||||
_result = null;
|
||||
try { _parseResult = EquipmentCsvImporter.Parse(_csvText); }
|
||||
catch (InvalidCsvFormatException ex) { _parseError = ex.Message; }
|
||||
catch (Exception ex) { _parseError = $"Parse failed: {ex.Message}"; }
|
||||
}
|
||||
|
||||
private async Task StageAndFinaliseAsync()
|
||||
{
|
||||
if (_parseResult is null) return;
|
||||
_busy = true;
|
||||
_result = null;
|
||||
_parseError = null;
|
||||
try
|
||||
{
|
||||
var auth = await AuthProvider.GetAuthenticationStateAsync();
|
||||
var createdBy = auth.User.Identity?.Name ?? "unknown";
|
||||
|
||||
var batch = await BatchSvc.CreateBatchAsync(ClusterId, createdBy, CancellationToken.None);
|
||||
await BatchSvc.StageRowsAsync(batch.Id, _parseResult.AcceptedRows, _parseResult.RejectedRows, CancellationToken.None);
|
||||
await BatchSvc.FinaliseBatchAsync(batch.Id, GenerationId, _driverInstanceId, _unsLineId, CancellationToken.None);
|
||||
|
||||
_result = $"Finalised batch {batch.Id:N} — {_parseResult.AcceptedRows.Count} rows added.";
|
||||
// Pause 600 ms so the success banner is visible, then navigate back.
|
||||
await Task.Delay(600);
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/draft/{GenerationId}");
|
||||
}
|
||||
catch (Exception ex) { _parseError = $"Finalise failed: {ex.Message}"; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject ClusterNodeService NodeSvc
|
||||
@inject NavigationManager Nav
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<h4>Redundancy topology</h4>
|
||||
@if (_roleChangedBanner is not null)
|
||||
{
|
||||
<div class="alert alert-info small mb-2">@_roleChangedBanner</div>
|
||||
}
|
||||
<p class="text-muted small">
|
||||
One row per <code>ClusterNode</code> in this cluster. Role, <code>ApplicationUri</code>,
|
||||
and <code>ServiceLevelBase</code> are authored separately; the Admin UI shows them read-only
|
||||
here so operators can confirm the published topology without touching it. LastSeen older than
|
||||
@((int)ClusterNodeService.StaleThreshold.TotalSeconds)s is flagged Stale — the node has
|
||||
stopped heart-beating and is likely down. Role swap goes through the server-side
|
||||
<code>RedundancyCoordinator</code> apply-lease flow, not direct DB edits.
|
||||
</p>
|
||||
|
||||
@if (_nodes is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_nodes.Count == 0)
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
No ClusterNode rows for this cluster. The server process needs at least one entry
|
||||
(with a non-blank <code>ApplicationUri</code>) before it can start up per OPC UA spec.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var primaries = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Primary);
|
||||
var secondaries = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Secondary);
|
||||
var standalone = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Standalone);
|
||||
var staleCount = _nodes.Count(ClusterNodeService.IsStale);
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Nodes</h6>
|
||||
<div class="fs-3">@_nodes.Count</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card border-success"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Primary</h6>
|
||||
<div class="fs-3 text-success">@primaries</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card border-info"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Secondary</h6>
|
||||
<div class="fs-3 text-info">@secondaries</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card @(staleCount > 0 ? "border-warning" : "")"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Stale</h6>
|
||||
<div class="fs-3 @(staleCount > 0 ? "text-warning" : "")">@staleCount</div>
|
||||
</div></div></div>
|
||||
</div>
|
||||
|
||||
@if (primaries == 0 && standalone == 0)
|
||||
{
|
||||
<div class="alert alert-danger small mb-3">
|
||||
No Primary or Standalone node — the cluster has no authoritative write target. Secondaries
|
||||
stay read-only until one of them gets promoted via <code>RedundancyCoordinator</code>.
|
||||
</div>
|
||||
}
|
||||
else if (primaries > 1)
|
||||
{
|
||||
<div class="alert alert-danger small mb-3">
|
||||
<strong>Split-brain:</strong> @primaries nodes claim the Primary role. Apply-lease
|
||||
enforcement should have made this impossible at the coordinator level. Investigate
|
||||
immediately — one of the rows was likely hand-edited.
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Role</th>
|
||||
<th>Host</th>
|
||||
<th class="text-end">OPC UA port</th>
|
||||
<th class="text-end">ServiceLevel base</th>
|
||||
<th>ApplicationUri</th>
|
||||
<th>Enabled</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var n in _nodes)
|
||||
{
|
||||
<tr class="@RowClass(n)">
|
||||
<td><code>@n.NodeId</code></td>
|
||||
<td><span class="badge @RoleBadge(n.RedundancyRole)">@n.RedundancyRole</span></td>
|
||||
<td>@n.Host</td>
|
||||
<td class="text-end"><code>@n.OpcUaPort</code></td>
|
||||
<td class="text-end">@n.ServiceLevelBase</td>
|
||||
<td class="small text-break"><code>@n.ApplicationUri</code></td>
|
||||
<td>
|
||||
@if (n.Enabled) { <span class="badge bg-success">Enabled</span> }
|
||||
else { <span class="badge bg-secondary">Disabled</span> }
|
||||
</td>
|
||||
<td class="small @(ClusterNodeService.IsStale(n) ? "text-warning fw-bold" : "")">
|
||||
@(n.LastSeenAt is null ? "never" : FormatAge(n.LastSeenAt.Value))
|
||||
@if (ClusterNodeService.IsStale(n)) { <span class="badge bg-warning text-dark ms-1">Stale</span> }
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<ClusterNode>? _nodes;
|
||||
private HubConnection? _hub;
|
||||
private string? _roleChangedBanner;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_nodes = await NodeSvc.ListByClusterAsync(ClusterId, CancellationToken.None);
|
||||
if (_hub is null) await ConnectHubAsync();
|
||||
}
|
||||
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hub.On<RoleChangedMessage>("RoleChanged", async msg =>
|
||||
{
|
||||
if (msg.ClusterId != ClusterId) return;
|
||||
_roleChangedBanner = $"Role changed on {msg.NodeId}: {msg.FromRole} → {msg.ToRole} at {msg.ObservedAtUtc:HH:mm:ss 'UTC'}";
|
||||
_nodes = await NodeSvc.ListByClusterAsync(ClusterId, CancellationToken.None);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
});
|
||||
|
||||
await _hub.StartAsync();
|
||||
await _hub.SendAsync("SubscribeCluster", ClusterId);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hub is not null)
|
||||
{
|
||||
await _hub.DisposeAsync();
|
||||
_hub = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RowClass(ClusterNode n) =>
|
||||
ClusterNodeService.IsStale(n) ? "table-warning" :
|
||||
!n.Enabled ? "table-secondary" : "";
|
||||
|
||||
private static string RoleBadge(RedundancyRole r) => r switch
|
||||
{
|
||||
RedundancyRole.Primary => "bg-success",
|
||||
RedundancyRole.Secondary => "bg-info",
|
||||
RedundancyRole.Standalone => "bg-primary",
|
||||
_ => "bg-secondary",
|
||||
};
|
||||
|
||||
private static string FormatAge(DateTime t)
|
||||
{
|
||||
var age = DateTime.UtcNow - t;
|
||||
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
||||
return t.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,13 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject UnsService UnsSvc
|
||||
|
||||
<div class="alert alert-info small mb-3">
|
||||
Drag any line in the <strong>UNS Lines</strong> table onto an area row in <strong>UNS Areas</strong>
|
||||
to re-parent it. A preview modal shows the impact (equipment re-home count) + lets you confirm
|
||||
or cancel. If another operator modifies the draft while you're confirming, you'll see a 409
|
||||
refresh-required modal instead of clobbering their work.
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
@@ -14,11 +21,20 @@
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>AreaId</th><th>Name</th></tr></thead>
|
||||
<thead><tr><th>AreaId</th><th>Name</th><th class="small text-muted">(drop target)</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var a in _areas)
|
||||
{
|
||||
<tr><td><code>@a.UnsAreaId</code></td><td>@a.Name</td></tr>
|
||||
<tr class="@(_hoverAreaId == a.UnsAreaId ? "table-primary" : "")"
|
||||
@ondragover="e => OnAreaDragOver(e, a.UnsAreaId)"
|
||||
@ondragover:preventDefault
|
||||
@ondragleave="() => _hoverAreaId = null"
|
||||
@ondrop="() => OnLineDroppedAsync(a.UnsAreaId)"
|
||||
@ondrop:preventDefault>
|
||||
<td><code>@a.UnsAreaId</code></td>
|
||||
<td>@a.Name</td>
|
||||
<td class="small text-muted">drop here</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -35,6 +51,7 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h4>UNS Lines</h4>
|
||||
@@ -50,7 +67,14 @@
|
||||
<tbody>
|
||||
@foreach (var l in _lines)
|
||||
{
|
||||
<tr><td><code>@l.UnsLineId</code></td><td><code>@l.UnsAreaId</code></td><td>@l.Name</td></tr>
|
||||
<tr draggable="true"
|
||||
@ondragstart="() => _dragLineId = l.UnsLineId"
|
||||
@ondragend="() => { _dragLineId = null; _hoverAreaId = null; }"
|
||||
style="cursor: grab;">
|
||||
<td><code>@l.UnsLineId</code></td>
|
||||
<td><code>@l.UnsAreaId</code></td>
|
||||
<td>@l.Name</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -75,6 +99,64 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Preview / confirm modal for a pending drag-drop move *@
|
||||
@if (_pendingPreview is not null)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm UNS move</h5>
|
||||
<button type="button" class="btn-close" @onclick="CancelMove"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>@_pendingPreview.HumanReadableSummary</p>
|
||||
<p class="text-muted small">
|
||||
Equipment re-homed: <strong>@_pendingPreview.AffectedEquipmentCount</strong>.
|
||||
Tags re-parented: <strong>@_pendingPreview.AffectedTagCount</strong>.
|
||||
</p>
|
||||
@if (_pendingPreview.CascadeWarnings.Count > 0)
|
||||
{
|
||||
<div class="alert alert-warning small mb-0">
|
||||
<ul class="mb-0">
|
||||
@foreach (var w in _pendingPreview.CascadeWarnings) { <li>@w</li> }
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @onclick="CancelMove">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="ConfirmMoveAsync" disabled="@_committing">Confirm move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* 409 concurrent-edit modal — another operator changed the draft between preview + commit *@
|
||||
@if (_conflictMessage is not null)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-danger">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title">Draft changed — refresh required</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>@_conflictMessage</p>
|
||||
<p class="small text-muted">
|
||||
Concurrency guard per DraftRevisionToken prevented overwriting the peer
|
||||
operator's edit. Reload the tab + redo the move on the current draft state.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" @onclick="ReloadAfterConflict">Reload draft</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
@@ -87,6 +169,13 @@
|
||||
private string _newLineName = string.Empty;
|
||||
private string _newLineAreaId = string.Empty;
|
||||
|
||||
private string? _dragLineId;
|
||||
private string? _hoverAreaId;
|
||||
private UnsImpactPreview? _pendingPreview;
|
||||
private UnsMoveOperation? _pendingMove;
|
||||
private bool _committing;
|
||||
private string? _conflictMessage;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
@@ -112,4 +201,72 @@
|
||||
_showLineForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
|
||||
private void OnAreaDragOver(DragEventArgs _, string areaId) => _hoverAreaId = areaId;
|
||||
|
||||
private async Task OnLineDroppedAsync(string targetAreaId)
|
||||
{
|
||||
var lineId = _dragLineId;
|
||||
_hoverAreaId = null;
|
||||
_dragLineId = null;
|
||||
if (string.IsNullOrWhiteSpace(lineId)) return;
|
||||
|
||||
var line = _lines?.FirstOrDefault(l => l.UnsLineId == lineId);
|
||||
if (line is null || line.UnsAreaId == targetAreaId) return;
|
||||
|
||||
var snapshot = await UnsSvc.LoadSnapshotAsync(GenerationId, CancellationToken.None);
|
||||
var move = new UnsMoveOperation(
|
||||
Kind: UnsMoveKind.LineMove,
|
||||
SourceClusterId: ClusterId,
|
||||
TargetClusterId: ClusterId,
|
||||
SourceLineId: lineId,
|
||||
TargetAreaId: targetAreaId);
|
||||
try
|
||||
{
|
||||
_pendingPreview = UnsImpactAnalyzer.Analyze(snapshot, move);
|
||||
_pendingMove = move;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_conflictMessage = ex.Message; // CrossCluster or validation failure surfaces here
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelMove()
|
||||
{
|
||||
_pendingPreview = null;
|
||||
_pendingMove = null;
|
||||
}
|
||||
|
||||
private async Task ConfirmMoveAsync()
|
||||
{
|
||||
if (_pendingPreview is null || _pendingMove is null) return;
|
||||
_committing = true;
|
||||
try
|
||||
{
|
||||
await UnsSvc.MoveLineAsync(
|
||||
GenerationId,
|
||||
_pendingPreview.RevisionToken,
|
||||
_pendingMove.SourceLineId!,
|
||||
_pendingMove.TargetAreaId!,
|
||||
CancellationToken.None);
|
||||
|
||||
_pendingPreview = null;
|
||||
_pendingMove = null;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (DraftRevisionConflictException ex)
|
||||
{
|
||||
_pendingPreview = null;
|
||||
_pendingMove = null;
|
||||
_conflictMessage = ex.Message;
|
||||
}
|
||||
finally { _committing = false; }
|
||||
}
|
||||
|
||||
private async Task ReloadAfterConflict()
|
||||
{
|
||||
_conflictMessage = null;
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,16 @@ else
|
||||
</div></div></div>
|
||||
</div>
|
||||
|
||||
@if (_rows.Any(HostStatusService.IsFlagged))
|
||||
{
|
||||
var flaggedCount = _rows.Count(HostStatusService.IsFlagged);
|
||||
<div class="alert alert-danger small mb-3">
|
||||
<strong>@flaggedCount host@(flaggedCount == 1 ? "" : "s")</strong>
|
||||
reporting ≥ @HostStatusService.FailureFlagThreshold consecutive failures — circuit breaker
|
||||
may trip soon. Inspect the resilience columns below to locate.
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (var cluster in _rows.GroupBy(r => r.ClusterId ?? "(unassigned)").OrderBy(g => g.Key))
|
||||
{
|
||||
<h2 class="h5 mt-4">Cluster: <code>@cluster.Key</code></h2>
|
||||
@@ -66,6 +76,9 @@ else
|
||||
<th>Driver</th>
|
||||
<th>Host</th>
|
||||
<th>State</th>
|
||||
<th class="text-end" title="Consecutive failures — resets when a call succeeds or the breaker closes">Fail#</th>
|
||||
<th class="text-end" title="In-flight capability calls (bulkhead-depth proxy)">In-flight</th>
|
||||
<th>Breaker opened</th>
|
||||
<th>Last transition</th>
|
||||
<th>Last seen</th>
|
||||
<th>Detail</th>
|
||||
@@ -84,10 +97,21 @@ else
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1">Stale</span>
|
||||
}
|
||||
@if (HostStatusService.IsFlagged(r))
|
||||
{
|
||||
<span class="badge bg-danger ms-1" title="≥ @HostStatusService.FailureFlagThreshold consecutive failures">Flagged</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end small @(HostStatusService.IsFlagged(r) ? "text-danger fw-bold" : "")">
|
||||
@r.ConsecutiveFailures
|
||||
</td>
|
||||
<td class="text-end small">@r.CurrentBulkheadDepth</td>
|
||||
<td class="small">
|
||||
@(r.LastCircuitBreakerOpenUtc is null ? "—" : FormatAge(r.LastCircuitBreakerOpenUtc.Value))
|
||||
</td>
|
||||
<td class="small">@FormatAge(r.StateChangedUtc)</td>
|
||||
<td class="small @(HostStatusService.IsStale(r) ? "text-warning" : "")">@FormatAge(r.LastSeenUtc)</td>
|
||||
<td class="text-truncate small" style="max-width: 320px;" title="@r.Detail">@r.Detail</td>
|
||||
<td class="text-truncate small" style="max-width: 240px;" title="@r.Detail">@r.Detail</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
|
||||
192
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor
Normal file
192
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor
Normal file
@@ -0,0 +1,192 @@
|
||||
@page "/role-grants"
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Services
|
||||
@inject ILdapGroupRoleMappingService RoleSvc
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject AclChangeNotifier Notifier
|
||||
@inject NavigationManager Nav
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<h1 class="mb-4">LDAP group → Admin role grants</h1>
|
||||
|
||||
<div class="alert alert-info small mb-4">
|
||||
Maps LDAP groups to Admin UI roles (ConfigViewer / ConfigEditor / FleetAdmin). Control-plane
|
||||
only — OPC UA data-path authorization reads <code>NodeAcl</code> rows directly and is
|
||||
unaffected by these mappings (see decision #150). A fleet-wide grant applies across every
|
||||
cluster; a cluster-scoped grant only binds within the named cluster. The same LDAP group
|
||||
may hold different roles on different clusters.
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add grant</button>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No role grants defined yet. Without at least one FleetAdmin grant,
|
||||
only the bootstrap admin can publish drafts.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr><th>LDAP group</th><th>Role</th><th>Scope</th><th>Created</th><th>Notes</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.LdapGroup</code></td>
|
||||
<td><span class="badge bg-secondary">@r.Role</span></td>
|
||||
<td>@(r.IsSystemWide ? "Fleet-wide" : $"Cluster: {r.ClusterId}")</td>
|
||||
<td class="small">@r.CreatedAtUtc.ToString("yyyy-MM-dd")</td>
|
||||
<td class="small text-muted">@r.Notes</td>
|
||||
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(r.Id)">Revoke</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h5>New role grant</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">LDAP group (DN)</label>
|
||||
<input class="form-control" @bind="_group" placeholder="cn=fleet-admin,ou=groups,dc=…"/>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Role</label>
|
||||
<select class="form-select" @bind="_role">
|
||||
@foreach (var r in Enum.GetValues<AdminRole>())
|
||||
{
|
||||
<option value="@r">@r</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 pt-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="systemWide" @bind="_isSystemWide"/>
|
||||
<label class="form-check-label" for="systemWide">Fleet-wide</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Cluster @(_isSystemWide ? "(disabled)" : "")</label>
|
||||
<select class="form-select" @bind="_clusterId" disabled="@_isSystemWide">
|
||||
<option value="">-- select --</option>
|
||||
@if (_clusters is not null)
|
||||
{
|
||||
@foreach (var c in _clusters)
|
||||
{
|
||||
<option value="@c.ClusterId">@c.ClusterId</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notes (optional)</label>
|
||||
<input class="form-control" @bind="_notes"/>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<LdapGroupRoleMapping>? _rows;
|
||||
private List<ServerCluster>? _clusters;
|
||||
private bool _showForm;
|
||||
private string _group = string.Empty;
|
||||
private AdminRole _role = AdminRole.ConfigViewer;
|
||||
private bool _isSystemWide;
|
||||
private string _clusterId = string.Empty;
|
||||
private string? _notes;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_rows = await RoleSvc.ListAllAsync(CancellationToken.None);
|
||||
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private void StartAdd()
|
||||
{
|
||||
_group = string.Empty;
|
||||
_role = AdminRole.ConfigViewer;
|
||||
_isSystemWide = false;
|
||||
_clusterId = string.Empty;
|
||||
_notes = null;
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var row = new LdapGroupRoleMapping
|
||||
{
|
||||
LdapGroup = _group.Trim(),
|
||||
Role = _role,
|
||||
IsSystemWide = _isSystemWide,
|
||||
ClusterId = _isSystemWide ? null : (string.IsNullOrWhiteSpace(_clusterId) ? null : _clusterId),
|
||||
Notes = string.IsNullOrWhiteSpace(_notes) ? null : _notes,
|
||||
};
|
||||
await RoleSvc.CreateAsync(row, CancellationToken.None);
|
||||
await Notifier.NotifyRoleGrantsChangedAsync(CancellationToken.None);
|
||||
_showForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Guid id)
|
||||
{
|
||||
await RoleSvc.DeleteAsync(id, CancellationToken.None);
|
||||
await Notifier.NotifyRoleGrantsChangedAsync(CancellationToken.None);
|
||||
await ReloadAsync();
|
||||
}
|
||||
|
||||
private HubConnection? _hub;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || _hub is not null) return;
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
_hub.On<RoleGrantsChangedMessage>("RoleGrantsChanged", async _ =>
|
||||
{
|
||||
await ReloadAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
});
|
||||
await _hub.StartAsync();
|
||||
await _hub.SendAsync("SubscribeFleet");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hub is not null) { await _hub.DisposeAsync(); _hub = null; }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
@@ -14,11 +16,13 @@ public sealed class FleetStatusPoller(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IHubContext<FleetStatusHub> fleetHub,
|
||||
IHubContext<AlertHub> alertHub,
|
||||
ILogger<FleetStatusPoller> logger) : BackgroundService
|
||||
ILogger<FleetStatusPoller> logger,
|
||||
RedundancyMetrics redundancyMetrics) : BackgroundService
|
||||
{
|
||||
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly Dictionary<string, NodeStateSnapshot> _last = new();
|
||||
private readonly Dictionary<string, RedundancyRole> _lastRole = new(StringComparer.Ordinal);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
@@ -42,6 +46,10 @@ public sealed class FleetStatusPoller(
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
|
||||
var nodes = await db.ClusterNodes.AsNoTracking().ToListAsync(ct);
|
||||
await PollRolesAsync(nodes, ct);
|
||||
UpdateClusterGauges(nodes);
|
||||
|
||||
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
|
||||
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new { s, n.ClusterId })
|
||||
.ToListAsync(ct);
|
||||
@@ -85,9 +93,63 @@ public sealed class FleetStatusPoller(
|
||||
}
|
||||
|
||||
/// <summary>Exposed for tests — forces a snapshot reset so stub data re-seeds.</summary>
|
||||
internal void ResetCache() => _last.Clear();
|
||||
internal void ResetCache()
|
||||
{
|
||||
_last.Clear();
|
||||
_lastRole.Clear();
|
||||
}
|
||||
|
||||
private async Task PollRolesAsync(IReadOnlyList<ClusterNode> nodes, CancellationToken ct)
|
||||
{
|
||||
foreach (var n in nodes)
|
||||
{
|
||||
var hadPrior = _lastRole.TryGetValue(n.NodeId, out var priorRole);
|
||||
if (hadPrior && priorRole == n.RedundancyRole) continue;
|
||||
|
||||
_lastRole[n.NodeId] = n.RedundancyRole;
|
||||
if (!hadPrior) continue; // first-observation bootstrap — not a transition
|
||||
|
||||
redundancyMetrics.RecordRoleTransition(
|
||||
clusterId: n.ClusterId, nodeId: n.NodeId,
|
||||
fromRole: priorRole.ToString(), toRole: n.RedundancyRole.ToString());
|
||||
|
||||
var msg = new RoleChangedMessage(
|
||||
ClusterId: n.ClusterId, NodeId: n.NodeId,
|
||||
FromRole: priorRole.ToString(), ToRole: n.RedundancyRole.ToString(),
|
||||
ObservedAtUtc: DateTime.UtcNow);
|
||||
|
||||
await fleetHub.Clients.Group(FleetStatusHub.GroupName(n.ClusterId))
|
||||
.SendAsync("RoleChanged", msg, ct);
|
||||
await fleetHub.Clients.Group(FleetStatusHub.FleetGroup)
|
||||
.SendAsync("RoleChanged", msg, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateClusterGauges(IReadOnlyList<ClusterNode> nodes)
|
||||
{
|
||||
var staleCutoff = DateTime.UtcNow - Services.ClusterNodeService.StaleThreshold;
|
||||
foreach (var group in nodes.GroupBy(n => n.ClusterId))
|
||||
{
|
||||
var primary = group.Count(n => n.RedundancyRole == RedundancyRole.Primary);
|
||||
var secondary = group.Count(n => n.RedundancyRole == RedundancyRole.Secondary);
|
||||
var stale = group.Count(n => n.LastSeenAt is null || n.LastSeenAt.Value < staleCutoff);
|
||||
redundancyMetrics.SetClusterCounts(group.Key, primary, secondary, stale);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct NodeStateSnapshot(
|
||||
string NodeId, string ClusterId, long? GenerationId,
|
||||
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushed by <see cref="FleetStatusPoller"/> when it observes a change in
|
||||
/// <see cref="ClusterNode.RedundancyRole"/>. Consumed by the Admin RedundancyTab to trigger
|
||||
/// an instant reload instead of waiting for the next on-parameter-set poll.
|
||||
/// </summary>
|
||||
public sealed record RoleChangedMessage(
|
||||
string ClusterId,
|
||||
string NodeId,
|
||||
string FromRole,
|
||||
string ToRole,
|
||||
DateTime ObservedAtUtc);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OpenTelemetry.Metrics;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Components;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
@@ -44,10 +45,17 @@ builder.Services.AddScoped<UnsService>();
|
||||
builder.Services.AddScoped<NamespaceService>();
|
||||
builder.Services.AddScoped<DriverInstanceService>();
|
||||
builder.Services.AddScoped<NodeAclService>();
|
||||
builder.Services.AddScoped<PermissionProbeService>();
|
||||
builder.Services.AddScoped<AclChangeNotifier>();
|
||||
builder.Services.AddScoped<ReservationService>();
|
||||
builder.Services.AddScoped<DraftValidationService>();
|
||||
builder.Services.AddScoped<AuditLogService>();
|
||||
builder.Services.AddScoped<HostStatusService>();
|
||||
builder.Services.AddScoped<ClusterNodeService>();
|
||||
builder.Services.AddSingleton<RedundancyMetrics>();
|
||||
builder.Services.AddScoped<EquipmentImportBatchService>();
|
||||
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
||||
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
|
||||
|
||||
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
|
||||
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
|
||||
@@ -63,6 +71,19 @@ builder.Services.AddScoped<ILdapAuthService, LdapAuthService>();
|
||||
// SignalR real-time fleet status + alerts (admin-ui.md §"Real-Time Updates").
|
||||
builder.Services.AddHostedService<FleetStatusPoller>();
|
||||
|
||||
// OpenTelemetry Prometheus exporter — Meter stream from RedundancyMetrics + any future
|
||||
// Admin-side instrumentation lands on the /metrics endpoint Prometheus scrapes. Pull-based
|
||||
// means no OTel Collector deployment required for the common deploy-in-a-K8s case; appsettings
|
||||
// Metrics:Prometheus:Enabled=false disables the endpoint entirely for locked-down deployments.
|
||||
var metricsEnabled = builder.Configuration.GetValue("Metrics:Prometheus:Enabled", true);
|
||||
if (metricsEnabled)
|
||||
{
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(m => m
|
||||
.AddMeter(RedundancyMetrics.MeterName)
|
||||
.AddPrometheusExporter());
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
@@ -80,6 +101,15 @@ app.MapPost("/auth/logout", async (HttpContext ctx) =>
|
||||
app.MapHub<FleetStatusHub>("/hubs/fleet");
|
||||
app.MapHub<AlertHub>("/hubs/alerts");
|
||||
|
||||
if (metricsEnabled)
|
||||
{
|
||||
// Prometheus scrape endpoint — expose instrumentation registered in the OTel MeterProvider
|
||||
// above. Emits text-format metrics at /metrics; auth is intentionally NOT required (Prometheus
|
||||
// scrape jobs typically run on a trusted network). Operators who need auth put the endpoint
|
||||
// behind a reverse-proxy basic-auth gate per fleet-ops convention.
|
||||
app.MapPrometheusScrapingEndpoint();
|
||||
}
|
||||
|
||||
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
49
src/ZB.MOM.WW.OtOpcUa.Admin/Services/AclChangeNotifier.cs
Normal file
49
src/ZB.MOM.WW.OtOpcUa.Admin/Services/AclChangeNotifier.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin SignalR push helper for ACL + role-grant invalidation — slice 2 of task #196.
|
||||
/// Lets the Admin services + razor pages invalidate connected peers' views without each
|
||||
/// one having to know the hub wiring. Two message kinds: <c>NodeAclChanged</c> (cluster-scoped)
|
||||
/// and <c>RoleGrantsChanged</c> (fleet-wide — role mappings cross cluster boundaries).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Intentionally fire-and-forget — a failed hub send doesn't rollback the DB write that
|
||||
/// triggered it. Worst-case an operator sees stale data until their next poll or manual
|
||||
/// refresh; better than a transient hub blip blocking the authoritative write path.
|
||||
/// </remarks>
|
||||
public sealed class AclChangeNotifier(IHubContext<FleetStatusHub> fleetHub, ILogger<AclChangeNotifier> logger)
|
||||
{
|
||||
public async Task NotifyNodeAclChangedAsync(string clusterId, long generationId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var msg = new NodeAclChangedMessage(ClusterId: clusterId, GenerationId: generationId, ObservedAtUtc: DateTime.UtcNow);
|
||||
await fleetHub.Clients.Group(FleetStatusHub.GroupName(clusterId))
|
||||
.SendAsync("NodeAclChanged", msg, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "NodeAclChanged push failed for cluster {ClusterId} gen {GenerationId}", clusterId, generationId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyRoleGrantsChangedAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var msg = new RoleGrantsChangedMessage(ObservedAtUtc: DateTime.UtcNow);
|
||||
await fleetHub.Clients.Group(FleetStatusHub.FleetGroup)
|
||||
.SendAsync("RoleGrantsChanged", msg, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "RoleGrantsChanged push failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record NodeAclChangedMessage(string ClusterId, long GenerationId, DateTime ObservedAtUtc);
|
||||
public sealed record RoleGrantsChangedMessage(DateTime ObservedAtUtc);
|
||||
28
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterNodeService.cs
Normal file
28
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterNodeService.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Read-side service for ClusterNode rows + their cluster-scoped redundancy view. Consumed
|
||||
/// by the RedundancyTab on the cluster detail page. Writes (role swap, node enable/disable)
|
||||
/// are not supported here — role swap happens through the RedundancyCoordinator apply-lease
|
||||
/// flow on the server side and would conflict with any direct DB mutation from Admin.
|
||||
/// </summary>
|
||||
public sealed class ClusterNodeService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>Stale-threshold matching <c>HostStatusService.StaleThreshold</c> — 30s of clock
|
||||
/// tolerance covers a missed heartbeat plus publisher GC pauses.</summary>
|
||||
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
|
||||
|
||||
public Task<List<ClusterNode>> ListByClusterAsync(string clusterId, CancellationToken ct) =>
|
||||
db.ClusterNodes.AsNoTracking()
|
||||
.Where(n => n.ClusterId == clusterId)
|
||||
.OrderByDescending(n => n.ServiceLevelBase)
|
||||
.ThenBy(n => n.NodeId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public static bool IsStale(ClusterNode node) =>
|
||||
node.LastSeenAt is null || DateTime.UtcNow - node.LastSeenAt.Value > StaleThreshold;
|
||||
}
|
||||
259
src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs
Normal file
259
src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs
Normal file
@@ -0,0 +1,259 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 4180 CSV parser for equipment import per decision #95 and Phase 6.4 Stream B.1.
|
||||
/// Produces a validated <see cref="EquipmentCsvParseResult"/> the caller (CSV import
|
||||
/// modal + staging tables) consumes. Pure-parser concern — no DB access, no staging
|
||||
/// writes; those live in the follow-up Stream B.2 work.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Header contract</b>: line 1 must be exactly <c># OtOpcUaCsv v1</c> (version
|
||||
/// marker). Line 2 is the column header row. Unknown columns are rejected; required
|
||||
/// columns must all be present. The version bump handshake lets future shapes parse
|
||||
/// without ambiguity — v2 files go through a different parser variant.</para>
|
||||
///
|
||||
/// <para><b>Required columns</b> per decision #117: ZTag, MachineCode, SAPID,
|
||||
/// EquipmentId, EquipmentUuid, Name, UnsAreaName, UnsLineName.</para>
|
||||
///
|
||||
/// <para><b>Optional columns</b> per decision #139: Manufacturer, Model, SerialNumber,
|
||||
/// HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation,
|
||||
/// ManufacturerUri, DeviceManualUri.</para>
|
||||
///
|
||||
/// <para><b>Row validation</b>: blank required field → rejected; duplicate ZTag within
|
||||
/// the same file → rejected. Duplicate against the DB isn't detected here — the
|
||||
/// staged-import finalize step (Stream B.4) catches that.</para>
|
||||
/// </remarks>
|
||||
public static class EquipmentCsvImporter
|
||||
{
|
||||
public const string VersionMarker = "# OtOpcUaCsv v1";
|
||||
|
||||
public static IReadOnlyList<string> RequiredColumns { get; } = new[]
|
||||
{
|
||||
"ZTag", "MachineCode", "SAPID", "EquipmentId", "EquipmentUuid",
|
||||
"Name", "UnsAreaName", "UnsLineName",
|
||||
};
|
||||
|
||||
public static IReadOnlyList<string> OptionalColumns { get; } = new[]
|
||||
{
|
||||
"Manufacturer", "Model", "SerialNumber", "HardwareRevision", "SoftwareRevision",
|
||||
"YearOfConstruction", "AssetLocation", "ManufacturerUri", "DeviceManualUri",
|
||||
};
|
||||
|
||||
public static EquipmentCsvParseResult Parse(string csvText)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(csvText);
|
||||
|
||||
var rows = SplitLines(csvText);
|
||||
if (rows.Count == 0)
|
||||
throw new InvalidCsvFormatException("CSV is empty.");
|
||||
|
||||
if (!string.Equals(rows[0].Trim(), VersionMarker, StringComparison.Ordinal))
|
||||
throw new InvalidCsvFormatException(
|
||||
$"CSV header line 1 must be exactly '{VersionMarker}' — got '{rows[0]}'. " +
|
||||
"Files without the version marker are rejected so future-format files don't parse ambiguously.");
|
||||
|
||||
if (rows.Count < 2)
|
||||
throw new InvalidCsvFormatException("CSV has no column header row (line 2) or data rows.");
|
||||
|
||||
var headerCells = SplitCsvRow(rows[1]);
|
||||
ValidateHeader(headerCells);
|
||||
|
||||
var accepted = new List<EquipmentCsvRow>();
|
||||
var rejected = new List<EquipmentCsvRowError>();
|
||||
var ztagsSeen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var colIndex = headerCells
|
||||
.Select((name, idx) => (name, idx))
|
||||
.ToDictionary(t => t.name, t => t.idx, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 2; i < rows.Count; i++)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rows[i])) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var cells = SplitCsvRow(rows[i]);
|
||||
if (cells.Length != headerCells.Length)
|
||||
{
|
||||
rejected.Add(new EquipmentCsvRowError(
|
||||
LineNumber: i + 1,
|
||||
Reason: $"Column count {cells.Length} != header count {headerCells.Length}."));
|
||||
continue;
|
||||
}
|
||||
|
||||
var row = BuildRow(cells, colIndex);
|
||||
var missing = RequiredColumns.Where(c => string.IsNullOrWhiteSpace(GetCell(row, c))).ToList();
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
rejected.Add(new EquipmentCsvRowError(i + 1, $"Blank required column(s): {string.Join(", ", missing)}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ztagsSeen.Add(row.ZTag))
|
||||
{
|
||||
rejected.Add(new EquipmentCsvRowError(i + 1, $"Duplicate ZTag '{row.ZTag}' within file."));
|
||||
continue;
|
||||
}
|
||||
|
||||
accepted.Add(row);
|
||||
}
|
||||
catch (InvalidCsvFormatException ex)
|
||||
{
|
||||
rejected.Add(new EquipmentCsvRowError(i + 1, ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
return new EquipmentCsvParseResult(accepted, rejected);
|
||||
}
|
||||
|
||||
private static void ValidateHeader(string[] headerCells)
|
||||
{
|
||||
var seen = new HashSet<string>(headerCells, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Missing required
|
||||
var missingRequired = RequiredColumns.Where(r => !seen.Contains(r)).ToList();
|
||||
if (missingRequired.Count > 0)
|
||||
throw new InvalidCsvFormatException($"Header is missing required column(s): {string.Join(", ", missingRequired)}");
|
||||
|
||||
// Unknown columns (not in required ∪ optional)
|
||||
var known = new HashSet<string>(RequiredColumns.Concat(OptionalColumns), StringComparer.OrdinalIgnoreCase);
|
||||
var unknown = headerCells.Where(c => !known.Contains(c)).ToList();
|
||||
if (unknown.Count > 0)
|
||||
throw new InvalidCsvFormatException(
|
||||
$"Header has unknown column(s): {string.Join(", ", unknown)}. " +
|
||||
"Bump the version marker to define a new shape before adding columns.");
|
||||
|
||||
// Duplicates
|
||||
var dupe = headerCells.GroupBy(c => c, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1);
|
||||
if (dupe is not null)
|
||||
throw new InvalidCsvFormatException($"Header has duplicate column '{dupe.Key}'.");
|
||||
}
|
||||
|
||||
private static EquipmentCsvRow BuildRow(string[] cells, Dictionary<string, int> colIndex) => new()
|
||||
{
|
||||
ZTag = cells[colIndex["ZTag"]],
|
||||
MachineCode = cells[colIndex["MachineCode"]],
|
||||
SAPID = cells[colIndex["SAPID"]],
|
||||
EquipmentId = cells[colIndex["EquipmentId"]],
|
||||
EquipmentUuid = cells[colIndex["EquipmentUuid"]],
|
||||
Name = cells[colIndex["Name"]],
|
||||
UnsAreaName = cells[colIndex["UnsAreaName"]],
|
||||
UnsLineName = cells[colIndex["UnsLineName"]],
|
||||
Manufacturer = colIndex.TryGetValue("Manufacturer", out var mi) ? cells[mi] : null,
|
||||
Model = colIndex.TryGetValue("Model", out var moi) ? cells[moi] : null,
|
||||
SerialNumber = colIndex.TryGetValue("SerialNumber", out var si) ? cells[si] : null,
|
||||
HardwareRevision = colIndex.TryGetValue("HardwareRevision", out var hi) ? cells[hi] : null,
|
||||
SoftwareRevision = colIndex.TryGetValue("SoftwareRevision", out var swi) ? cells[swi] : null,
|
||||
YearOfConstruction = colIndex.TryGetValue("YearOfConstruction", out var yi) ? cells[yi] : null,
|
||||
AssetLocation = colIndex.TryGetValue("AssetLocation", out var ai) ? cells[ai] : null,
|
||||
ManufacturerUri = colIndex.TryGetValue("ManufacturerUri", out var mui) ? cells[mui] : null,
|
||||
DeviceManualUri = colIndex.TryGetValue("DeviceManualUri", out var dui) ? cells[dui] : null,
|
||||
};
|
||||
|
||||
private static string GetCell(EquipmentCsvRow row, string colName) => colName switch
|
||||
{
|
||||
"ZTag" => row.ZTag,
|
||||
"MachineCode" => row.MachineCode,
|
||||
"SAPID" => row.SAPID,
|
||||
"EquipmentId" => row.EquipmentId,
|
||||
"EquipmentUuid" => row.EquipmentUuid,
|
||||
"Name" => row.Name,
|
||||
"UnsAreaName" => row.UnsAreaName,
|
||||
"UnsLineName" => row.UnsLineName,
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>Split the raw text on line boundaries. Handles \r\n + \n + \r.</summary>
|
||||
private static List<string> SplitLines(string csv) =>
|
||||
csv.Split(["\r\n", "\n", "\r"], StringSplitOptions.None).ToList();
|
||||
|
||||
/// <summary>Split one CSV row with RFC 4180 quoted-field handling.</summary>
|
||||
private static string[] SplitCsvRow(string row)
|
||||
{
|
||||
var cells = new List<string>();
|
||||
var sb = new StringBuilder();
|
||||
var inQuotes = false;
|
||||
|
||||
for (var i = 0; i < row.Length; i++)
|
||||
{
|
||||
var ch = row[i];
|
||||
if (inQuotes)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
// Escaped quote "" inside quoted field.
|
||||
if (i + 1 < row.Length && row[i + 1] == '"')
|
||||
{
|
||||
sb.Append('"');
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
inQuotes = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(ch);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ch == ',')
|
||||
{
|
||||
cells.Add(sb.ToString());
|
||||
sb.Clear();
|
||||
}
|
||||
else if (ch == '"' && sb.Length == 0)
|
||||
{
|
||||
inQuotes = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cells.Add(sb.ToString());
|
||||
return cells.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One parsed equipment row with required + optional fields.</summary>
|
||||
public sealed class EquipmentCsvRow
|
||||
{
|
||||
// Required (decision #117)
|
||||
public required string ZTag { get; init; }
|
||||
public required string MachineCode { get; init; }
|
||||
public required string SAPID { get; init; }
|
||||
public required string EquipmentId { get; init; }
|
||||
public required string EquipmentUuid { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string UnsAreaName { get; init; }
|
||||
public required string UnsLineName { get; init; }
|
||||
|
||||
// Optional (decision #139 — OPC 40010 Identification fields)
|
||||
public string? Manufacturer { get; init; }
|
||||
public string? Model { get; init; }
|
||||
public string? SerialNumber { get; init; }
|
||||
public string? HardwareRevision { get; init; }
|
||||
public string? SoftwareRevision { get; init; }
|
||||
public string? YearOfConstruction { get; init; }
|
||||
public string? AssetLocation { get; init; }
|
||||
public string? ManufacturerUri { get; init; }
|
||||
public string? DeviceManualUri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One row-level rejection captured by the parser. Line-number is 1-based in the source file.</summary>
|
||||
public sealed record EquipmentCsvRowError(int LineNumber, string Reason);
|
||||
|
||||
/// <summary>Parser output — accepted rows land in staging; rejected rows surface in the preview modal.</summary>
|
||||
public sealed record EquipmentCsvParseResult(
|
||||
IReadOnlyList<EquipmentCsvRow> AcceptedRows,
|
||||
IReadOnlyList<EquipmentCsvRowError> RejectedRows);
|
||||
|
||||
/// <summary>Thrown for file-level format problems (missing version marker, bad header, etc.).</summary>
|
||||
public sealed class InvalidCsvFormatException(string message) : Exception(message);
|
||||
@@ -0,0 +1,324 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Staged-import orchestrator per Phase 6.4 Stream B.2-B.4. Covers the four operator
|
||||
/// actions: CreateBatch → StageRows (chunked) → FinaliseBatch (atomic apply into
|
||||
/// <see cref="Equipment"/>) → DropBatch (rollback of pre-finalise state).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>FinaliseBatch runs inside one EF transaction + bulk-inserts accepted rows into
|
||||
/// <see cref="Equipment"/>. Rejected rows stay behind as audit evidence; the batch row
|
||||
/// gains <see cref="EquipmentImportBatch.FinalisedAtUtc"/> so future writes know it's
|
||||
/// archived. DropBatch removes the batch + its cascaded rows.</para>
|
||||
///
|
||||
/// <para>Idempotence: calling FinaliseBatch twice throws <see cref="ImportBatchAlreadyFinalisedException"/>
|
||||
/// rather than double-inserting. Operator refreshes the admin page to see the first
|
||||
/// finalise completed.</para>
|
||||
///
|
||||
/// <para>ExternalIdReservation merging (ZTag + SAPID uniqueness) is NOT done here — a
|
||||
/// narrower follow-up wires it once the concurrent-insert test matrix is green.</para>
|
||||
/// </remarks>
|
||||
public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>Create a new empty batch header. Returns the row with Id populated.</summary>
|
||||
public async Task<EquipmentImportBatch> CreateBatchAsync(string clusterId, string createdBy, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(createdBy);
|
||||
|
||||
var batch = new EquipmentImportBatch
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ClusterId = clusterId,
|
||||
CreatedBy = createdBy,
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
};
|
||||
db.EquipmentImportBatches.Add(batch);
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
return batch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stage one chunk of rows into the batch. Caller usually feeds
|
||||
/// <see cref="EquipmentCsvImporter.Parse"/> output here — each
|
||||
/// <see cref="EquipmentCsvRow"/> becomes one accepted <see cref="EquipmentImportRow"/>,
|
||||
/// each rejected parser error becomes one row with <see cref="EquipmentImportRow.IsAccepted"/> false.
|
||||
/// </summary>
|
||||
public async Task StageRowsAsync(
|
||||
Guid batchId,
|
||||
IReadOnlyList<EquipmentCsvRow> acceptedRows,
|
||||
IReadOnlyList<EquipmentCsvRowError> rejectedRows,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var batch = await db.EquipmentImportBatches.FirstOrDefaultAsync(b => b.Id == batchId, ct).ConfigureAwait(false)
|
||||
?? throw new ImportBatchNotFoundException($"Batch {batchId} not found.");
|
||||
|
||||
if (batch.FinalisedAtUtc is not null)
|
||||
throw new ImportBatchAlreadyFinalisedException(
|
||||
$"Batch {batchId} finalised at {batch.FinalisedAtUtc:o}; no more rows can be staged.");
|
||||
|
||||
foreach (var row in acceptedRows)
|
||||
{
|
||||
db.EquipmentImportRows.Add(new EquipmentImportRow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
IsAccepted = true,
|
||||
ZTag = row.ZTag,
|
||||
MachineCode = row.MachineCode,
|
||||
SAPID = row.SAPID,
|
||||
EquipmentId = row.EquipmentId,
|
||||
EquipmentUuid = row.EquipmentUuid,
|
||||
Name = row.Name,
|
||||
UnsAreaName = row.UnsAreaName,
|
||||
UnsLineName = row.UnsLineName,
|
||||
Manufacturer = row.Manufacturer,
|
||||
Model = row.Model,
|
||||
SerialNumber = row.SerialNumber,
|
||||
HardwareRevision = row.HardwareRevision,
|
||||
SoftwareRevision = row.SoftwareRevision,
|
||||
YearOfConstruction = row.YearOfConstruction,
|
||||
AssetLocation = row.AssetLocation,
|
||||
ManufacturerUri = row.ManufacturerUri,
|
||||
DeviceManualUri = row.DeviceManualUri,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var error in rejectedRows)
|
||||
{
|
||||
db.EquipmentImportRows.Add(new EquipmentImportRow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BatchId = batchId,
|
||||
IsAccepted = false,
|
||||
RejectReason = error.Reason,
|
||||
LineNumberInFile = error.LineNumber,
|
||||
// Required columns need values for EF; reject rows use sentinel placeholders.
|
||||
ZTag = "", MachineCode = "", SAPID = "", EquipmentId = "", EquipmentUuid = "",
|
||||
Name = "", UnsAreaName = "", UnsLineName = "",
|
||||
});
|
||||
}
|
||||
|
||||
batch.RowsStaged += acceptedRows.Count + rejectedRows.Count;
|
||||
batch.RowsAccepted += acceptedRows.Count;
|
||||
batch.RowsRejected += rejectedRows.Count;
|
||||
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Drop the batch (pre-finalise rollback). Cascaded row delete removes staged rows.</summary>
|
||||
public async Task DropBatchAsync(Guid batchId, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.EquipmentImportBatches.FirstOrDefaultAsync(b => b.Id == batchId, ct).ConfigureAwait(false);
|
||||
if (batch is null) return;
|
||||
if (batch.FinalisedAtUtc is not null)
|
||||
throw new ImportBatchAlreadyFinalisedException(
|
||||
$"Batch {batchId} already finalised at {batch.FinalisedAtUtc:o}; cannot drop.");
|
||||
|
||||
db.EquipmentImportBatches.Remove(batch);
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomic finalise. Inserts every accepted row into the live
|
||||
/// <see cref="Equipment"/> table under the target generation + stamps
|
||||
/// <see cref="EquipmentImportBatch.FinalisedAtUtc"/>. Failure rolls the whole tx
|
||||
/// back — <see cref="Equipment"/> never partially mutates.
|
||||
/// </summary>
|
||||
public async Task FinaliseBatchAsync(
|
||||
Guid batchId, long generationId, string driverInstanceIdForRows, string unsLineIdForRows, CancellationToken ct)
|
||||
{
|
||||
var batch = await db.EquipmentImportBatches
|
||||
.Include(b => b.Rows)
|
||||
.FirstOrDefaultAsync(b => b.Id == batchId, ct)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new ImportBatchNotFoundException($"Batch {batchId} not found.");
|
||||
|
||||
if (batch.FinalisedAtUtc is not null)
|
||||
throw new ImportBatchAlreadyFinalisedException(
|
||||
$"Batch {batchId} already finalised at {batch.FinalisedAtUtc:o}.");
|
||||
|
||||
// EF InMemory provider doesn't honour BeginTransaction; SQL Server provider does.
|
||||
// Tests run the happy path under in-memory; production SQL Server runs the atomic tx.
|
||||
var supportsTx = db.Database.IsRelational();
|
||||
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
|
||||
if (supportsTx)
|
||||
tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Snapshot active reservations that overlap this batch's ZTag + SAPID set — one
|
||||
// round-trip instead of N. Released rows (ReleasedAt IS NOT NULL) are ignored so
|
||||
// an explicitly-released value can be reused.
|
||||
var accepted = batch.Rows.Where(r => r.IsAccepted).ToList();
|
||||
var zTags = accepted.Where(r => !string.IsNullOrWhiteSpace(r.ZTag))
|
||||
.Select(r => r.ZTag).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var sapIds = accepted.Where(r => !string.IsNullOrWhiteSpace(r.SAPID))
|
||||
.Select(r => r.SAPID).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
var existingReservations = await db.ExternalIdReservations
|
||||
.Where(r => r.ReleasedAt == null &&
|
||||
((r.Kind == ReservationKind.ZTag && zTags.Contains(r.Value)) ||
|
||||
(r.Kind == ReservationKind.SAPID && sapIds.Contains(r.Value))))
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
var resByKey = existingReservations.ToDictionary(
|
||||
r => (r.Kind, r.Value.ToLowerInvariant()),
|
||||
r => r);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var firstPublishedBy = batch.CreatedBy;
|
||||
|
||||
foreach (var row in accepted)
|
||||
{
|
||||
var equipmentUuid = Guid.TryParse(row.EquipmentUuid, out var u) ? u : Guid.NewGuid();
|
||||
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(),
|
||||
GenerationId = generationId,
|
||||
EquipmentId = row.EquipmentId,
|
||||
EquipmentUuid = equipmentUuid,
|
||||
DriverInstanceId = driverInstanceIdForRows,
|
||||
UnsLineId = unsLineIdForRows,
|
||||
Name = row.Name,
|
||||
MachineCode = row.MachineCode,
|
||||
ZTag = row.ZTag,
|
||||
SAPID = row.SAPID,
|
||||
Manufacturer = row.Manufacturer,
|
||||
Model = row.Model,
|
||||
SerialNumber = row.SerialNumber,
|
||||
HardwareRevision = row.HardwareRevision,
|
||||
SoftwareRevision = row.SoftwareRevision,
|
||||
YearOfConstruction = short.TryParse(row.YearOfConstruction, out var y) ? y : null,
|
||||
AssetLocation = row.AssetLocation,
|
||||
ManufacturerUri = row.ManufacturerUri,
|
||||
DeviceManualUri = row.DeviceManualUri,
|
||||
});
|
||||
|
||||
MergeReservation(row.ZTag, ReservationKind.ZTag, equipmentUuid, batch.ClusterId,
|
||||
firstPublishedBy, nowUtc, resByKey);
|
||||
MergeReservation(row.SAPID, ReservationKind.SAPID, equipmentUuid, batch.ClusterId,
|
||||
firstPublishedBy, nowUtc, resByKey);
|
||||
}
|
||||
|
||||
batch.FinalisedAtUtc = nowUtc;
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsReservationUniquenessViolation(ex))
|
||||
{
|
||||
throw new ExternalIdReservationConflictException(
|
||||
"Finalise rejected: one or more ZTag/SAPID values were reserved by another operator " +
|
||||
"between batch preview and commit. Inspect active reservations + retry after resolving the conflict.",
|
||||
ex);
|
||||
}
|
||||
if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (tx is not null) await tx.RollbackAsync(ct).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tx is not null) await tx.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge one external-ID reservation for an equipment row. Three outcomes:
|
||||
/// (1) value is empty → skip; (2) reservation exists for same <paramref name="equipmentUuid"/>
|
||||
/// → bump <c>LastPublishedAt</c>; (3) reservation exists for a different EquipmentUuid
|
||||
/// → throw <see cref="ExternalIdReservationConflictException"/> with the conflicting UUID
|
||||
/// so the caller sees which equipment already owns the value; (4) no reservation → create new.
|
||||
/// </summary>
|
||||
private void MergeReservation(
|
||||
string? value,
|
||||
ReservationKind kind,
|
||||
Guid equipmentUuid,
|
||||
string clusterId,
|
||||
string firstPublishedBy,
|
||||
DateTime nowUtc,
|
||||
Dictionary<(ReservationKind, string), ExternalIdReservation> cache)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return;
|
||||
|
||||
var key = (kind, value.ToLowerInvariant());
|
||||
if (cache.TryGetValue(key, out var existing))
|
||||
{
|
||||
if (existing.EquipmentUuid != equipmentUuid)
|
||||
throw new ExternalIdReservationConflictException(
|
||||
$"{kind} '{value}' is already reserved by EquipmentUuid {existing.EquipmentUuid} " +
|
||||
$"(first published {existing.FirstPublishedAt:u} on cluster '{existing.ClusterId}'). " +
|
||||
$"Refusing to re-assign to {equipmentUuid}.");
|
||||
|
||||
existing.LastPublishedAt = nowUtc;
|
||||
return;
|
||||
}
|
||||
|
||||
var fresh = new ExternalIdReservation
|
||||
{
|
||||
ReservationId = Guid.NewGuid(),
|
||||
Kind = kind,
|
||||
Value = value,
|
||||
EquipmentUuid = equipmentUuid,
|
||||
ClusterId = clusterId,
|
||||
FirstPublishedAt = nowUtc,
|
||||
FirstPublishedBy = firstPublishedBy,
|
||||
LastPublishedAt = nowUtc,
|
||||
};
|
||||
db.ExternalIdReservations.Add(fresh);
|
||||
cache[key] = fresh;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the <see cref="DbUpdateException"/> root-cause was the filtered-unique
|
||||
/// index <c>UX_ExternalIdReservation_KindValue_Active</c> — i.e. another transaction
|
||||
/// won the race between our cache-load + commit. SQL Server surfaces this as 2601 / 2627.
|
||||
/// </summary>
|
||||
private static bool IsReservationUniquenessViolation(DbUpdateException ex)
|
||||
{
|
||||
for (Exception? inner = ex; inner is not null; inner = inner.InnerException)
|
||||
{
|
||||
if (inner is Microsoft.Data.SqlClient.SqlException sql &&
|
||||
(sql.Number == 2601 || sql.Number == 2627) &&
|
||||
sql.Message.Contains("UX_ExternalIdReservation_KindValue_Active", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>List batches created by the given user. Finalised batches are archived; include them on demand.</summary>
|
||||
public async Task<IReadOnlyList<EquipmentImportBatch>> ListByUserAsync(string createdBy, bool includeFinalised, CancellationToken ct)
|
||||
{
|
||||
var query = db.EquipmentImportBatches.AsNoTracking().Where(b => b.CreatedBy == createdBy);
|
||||
if (!includeFinalised)
|
||||
query = query.Where(b => b.FinalisedAtUtc == null);
|
||||
return await query.OrderByDescending(b => b.CreatedAtUtc).ToListAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ImportBatchNotFoundException(string message) : Exception(message);
|
||||
public sealed class ImportBatchAlreadyFinalisedException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a <c>FinaliseBatchAsync</c> call detects that one of its ZTag/SAPID values is
|
||||
/// already reserved by a different EquipmentUuid — either from a prior published generation
|
||||
/// or a concurrent finalise that won the race. The operator sees the message + the conflicting
|
||||
/// equipment ownership so they can resolve the conflict (pick a new ZTag, release the existing
|
||||
/// reservation via <c>sp_ReleaseExternalIdReservation</c>, etc.) and retry the finalise.
|
||||
/// </summary>
|
||||
public sealed class ExternalIdReservationConflictException : Exception
|
||||
{
|
||||
public ExternalIdReservationConflictException(string message) : base(message) { }
|
||||
public ExternalIdReservationConflictException(string message, Exception inner) : base(message, inner) { }
|
||||
}
|
||||
@@ -7,8 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// One row per <see cref="DriverHostStatus"/> record, enriched with the owning
|
||||
/// <c>ClusterNode.ClusterId</c> when available (left-join). The Admin <c>/hosts</c> page
|
||||
/// groups by cluster and renders a per-node → per-driver → per-host tree.
|
||||
/// <c>ClusterNode.ClusterId</c> (left-join) + the per-<c>(DriverInstanceId, HostName)</c>
|
||||
/// <see cref="DriverInstanceResilienceStatus"/> counters (also left-join) so the Admin
|
||||
/// <c>/hosts</c> page renders the resilience surface inline with host state.
|
||||
/// </summary>
|
||||
public sealed record HostStatusRow(
|
||||
string NodeId,
|
||||
@@ -18,7 +19,11 @@ public sealed record HostStatusRow(
|
||||
DriverHostState State,
|
||||
DateTime StateChangedUtc,
|
||||
DateTime LastSeenUtc,
|
||||
string? Detail);
|
||||
string? Detail,
|
||||
int ConsecutiveFailures,
|
||||
DateTime? LastCircuitBreakerOpenUtc,
|
||||
int CurrentBulkheadDepth,
|
||||
DateTime? LastRecycleUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Read-side service for the Admin UI's per-host drill-down. Loads
|
||||
@@ -36,15 +41,26 @@ public sealed class HostStatusService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Consecutive-failure threshold at which <see cref="IsFlagged"/> returns <c>true</c>
|
||||
/// so the Admin UI can paint a red badge. Matches Phase 6.1 decision #143's conservative
|
||||
/// half-of-breaker-threshold convention — flags before the breaker actually opens.</summary>
|
||||
public const int FailureFlagThreshold = 3;
|
||||
|
||||
public async Task<IReadOnlyList<HostStatusRow>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
// LEFT JOIN on NodeId so a row persists even when its owning ClusterNode row hasn't
|
||||
// been created yet (first-boot bootstrap case — keeps the UI from losing sight of
|
||||
// the reporting server).
|
||||
// Two LEFT JOINs:
|
||||
// 1. ClusterNodes on NodeId — row persists even when its owning ClusterNode row
|
||||
// hasn't been created yet (first-boot bootstrap case).
|
||||
// 2. DriverInstanceResilienceStatuses on (DriverInstanceId, HostName) — resilience
|
||||
// counters haven't been sampled yet for brand-new hosts, so a missing row means
|
||||
// zero failures + never-opened breaker.
|
||||
var rows = await (from s in db.DriverHostStatuses.AsNoTracking()
|
||||
join n in db.ClusterNodes.AsNoTracking()
|
||||
on s.NodeId equals n.NodeId into nodeJoin
|
||||
from n in nodeJoin.DefaultIfEmpty()
|
||||
join r in db.DriverInstanceResilienceStatuses.AsNoTracking()
|
||||
on new { s.DriverInstanceId, s.HostName } equals new { r.DriverInstanceId, r.HostName } into resilJoin
|
||||
from r in resilJoin.DefaultIfEmpty()
|
||||
orderby s.NodeId, s.DriverInstanceId, s.HostName
|
||||
select new HostStatusRow(
|
||||
s.NodeId,
|
||||
@@ -54,10 +70,21 @@ public sealed class HostStatusService(OtOpcUaConfigDbContext db)
|
||||
s.State,
|
||||
s.StateChangedUtc,
|
||||
s.LastSeenUtc,
|
||||
s.Detail)).ToListAsync(ct);
|
||||
s.Detail,
|
||||
r != null ? r.ConsecutiveFailures : 0,
|
||||
r != null ? r.LastCircuitBreakerOpenUtc : null,
|
||||
r != null ? r.CurrentBulkheadDepth : 0,
|
||||
r != null ? r.LastRecycleUtc : null)).ToListAsync(ct);
|
||||
return rows;
|
||||
}
|
||||
|
||||
public static bool IsStale(HostStatusRow row) =>
|
||||
DateTime.UtcNow - row.LastSeenUtc > StaleThreshold;
|
||||
|
||||
/// <summary>
|
||||
/// Red-badge predicate — <c>true</c> when the host has accumulated enough consecutive
|
||||
/// failures that an operator should take notice before the breaker trips.
|
||||
/// </summary>
|
||||
public static bool IsFlagged(HostStatusRow row) =>
|
||||
row.ConsecutiveFailures >= FailureFlagThreshold;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
public sealed class NodeAclService(OtOpcUaConfigDbContext db)
|
||||
public sealed class NodeAclService(OtOpcUaConfigDbContext db, AclChangeNotifier? notifier = null)
|
||||
{
|
||||
public Task<List<NodeAcl>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.NodeAcls.AsNoTracking()
|
||||
@@ -31,6 +31,10 @@ public sealed class NodeAclService(OtOpcUaConfigDbContext db)
|
||||
};
|
||||
db.NodeAcls.Add(acl);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
if (notifier is not null)
|
||||
await notifier.NotifyNodeAclChangedAsync(clusterId, draftId, ct);
|
||||
|
||||
return acl;
|
||||
}
|
||||
|
||||
@@ -40,5 +44,8 @@ public sealed class NodeAclService(OtOpcUaConfigDbContext db)
|
||||
if (row is null) return;
|
||||
db.NodeAcls.Remove(row);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
if (notifier is not null)
|
||||
await notifier.NotifyNodeAclChangedAsync(row.ClusterId, row.GenerationId, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Runs an ad-hoc permission probe against a draft or published generation's NodeAcl rows —
|
||||
/// "if LDAP group X asks for permission Y on node Z, would the trie grant it, and which
|
||||
/// rows contributed?" Powers the AclsTab "Probe this permission" form per the #196 sub-slice.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Thin wrapper over <see cref="PermissionTrieBuilder"/> + <see cref="PermissionTrie.CollectMatches"/> —
|
||||
/// the same code path the Server's dispatch layer uses at request time, so a probe result
|
||||
/// is guaranteed to match what the live server would decide. The probe is read-only + has
|
||||
/// no side effects; failing probes do NOT generate audit log rows.
|
||||
/// </remarks>
|
||||
public sealed class PermissionProbeService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate <paramref name="required"/> against the NodeAcl rows of
|
||||
/// <paramref name="generationId"/> for a request by <paramref name="ldapGroup"/> at
|
||||
/// <paramref name="scope"/>. Returns whether the permission would be granted + the list
|
||||
/// of matching grants so the UI can show *why*.
|
||||
/// </summary>
|
||||
public async Task<PermissionProbeResult> ProbeAsync(
|
||||
long generationId,
|
||||
string ldapGroup,
|
||||
NodeScope scope,
|
||||
NodePermissions required,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(ldapGroup);
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
|
||||
var rows = await db.NodeAcls.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId && a.ClusterId == scope.ClusterId)
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var trie = PermissionTrieBuilder.Build(scope.ClusterId, generationId, rows);
|
||||
var matches = trie.CollectMatches(scope, [ldapGroup]);
|
||||
|
||||
var effective = NodePermissions.None;
|
||||
foreach (var m in matches)
|
||||
effective |= m.PermissionFlags;
|
||||
|
||||
var granted = (effective & required) == required;
|
||||
return new PermissionProbeResult(
|
||||
Granted: granted,
|
||||
Required: required,
|
||||
Effective: effective,
|
||||
Matches: matches);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a <see cref="PermissionProbeService.ProbeAsync"/> call.</summary>
|
||||
public sealed record PermissionProbeResult(
|
||||
bool Granted,
|
||||
NodePermissions Required,
|
||||
NodePermissions Effective,
|
||||
IReadOnlyList<MatchedGrant> Matches);
|
||||
102
src/ZB.MOM.WW.OtOpcUa.Admin/Services/RedundancyMetrics.cs
Normal file
102
src/ZB.MOM.WW.OtOpcUa.Admin/Services/RedundancyMetrics.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// OpenTelemetry-compatible instrumentation for the redundancy surface. Uses in-box
|
||||
/// <see cref="System.Diagnostics.Metrics"/> so no NuGet dependency is required to emit —
|
||||
/// any MeterListener (dotnet-counters, OpenTelemetry.Extensions.Hosting OTLP exporter,
|
||||
/// Prometheus exporter, etc.) picks up the instruments by the <see cref="MeterName"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exporter configuration (OTLP, Prometheus, etc.) is intentionally NOT wired here —
|
||||
/// that's a deployment-ops decision that belongs in <c>Program.cs</c> behind an
|
||||
/// <c>appsettings</c> toggle. This class owns only the Meter + instruments so the
|
||||
/// production data stream exists regardless of exporter availability.
|
||||
///
|
||||
/// Counter + gauge names follow the otel-semantic-conventions pattern:
|
||||
/// <c>otopcua.redundancy.*</c> with tags for ClusterId + (for transitions) FromRole/ToRole/NodeId.
|
||||
/// </remarks>
|
||||
public sealed class RedundancyMetrics : IDisposable
|
||||
{
|
||||
public const string MeterName = "ZB.MOM.WW.OtOpcUa.Redundancy";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _roleTransitions;
|
||||
private readonly object _gaugeLock = new();
|
||||
private readonly Dictionary<string, ClusterGaugeState> _gaugeState = new();
|
||||
|
||||
public RedundancyMetrics()
|
||||
{
|
||||
_meter = new Meter(MeterName, version: "1.0.0");
|
||||
_roleTransitions = _meter.CreateCounter<long>(
|
||||
"otopcua.redundancy.role_transition",
|
||||
unit: "{transition}",
|
||||
description: "Observed RedundancyRole changes per node — tagged FromRole, ToRole, NodeId, ClusterId.");
|
||||
|
||||
// Observable gauges — the callback reports whatever the last Observe*Count call stashed.
|
||||
_meter.CreateObservableGauge(
|
||||
"otopcua.redundancy.primary_count",
|
||||
ObservePrimaryCounts,
|
||||
unit: "{node}",
|
||||
description: "Count of Primary-role nodes per cluster (should be 1 for N+1 redundant clusters, 0 during failover).");
|
||||
_meter.CreateObservableGauge(
|
||||
"otopcua.redundancy.secondary_count",
|
||||
ObserveSecondaryCounts,
|
||||
unit: "{node}",
|
||||
description: "Count of Secondary-role nodes per cluster.");
|
||||
_meter.CreateObservableGauge(
|
||||
"otopcua.redundancy.stale_count",
|
||||
ObserveStaleCounts,
|
||||
unit: "{node}",
|
||||
description: "Count of cluster nodes whose LastSeenAt is older than StaleThreshold.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the per-cluster snapshot consumed by the ObservableGauges. Poller calls this
|
||||
/// at the end of every tick so the collectors see fresh numbers on the next observation
|
||||
/// window (by default 1s for dotnet-counters, configurable per exporter).
|
||||
/// </summary>
|
||||
public void SetClusterCounts(string clusterId, int primary, int secondary, int stale)
|
||||
{
|
||||
lock (_gaugeLock)
|
||||
{
|
||||
_gaugeState[clusterId] = new ClusterGaugeState(primary, secondary, stale);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Increment the role_transition counter when a node's RedundancyRole changes. Tags
|
||||
/// allow breakdowns by from/to roles (e.g. Primary → Secondary for planned failover vs
|
||||
/// Primary → Standalone for emergency recovery) + by cluster for multi-site fleets.
|
||||
/// </summary>
|
||||
public void RecordRoleTransition(string clusterId, string nodeId, string fromRole, string toRole)
|
||||
{
|
||||
_roleTransitions.Add(1,
|
||||
new KeyValuePair<string, object?>("cluster.id", clusterId),
|
||||
new KeyValuePair<string, object?>("node.id", nodeId),
|
||||
new KeyValuePair<string, object?>("from_role", fromRole),
|
||||
new KeyValuePair<string, object?>("to_role", toRole));
|
||||
}
|
||||
|
||||
public void Dispose() => _meter.Dispose();
|
||||
|
||||
private IEnumerable<Measurement<long>> ObservePrimaryCounts() => SnapshotGauge(s => s.Primary);
|
||||
private IEnumerable<Measurement<long>> ObserveSecondaryCounts() => SnapshotGauge(s => s.Secondary);
|
||||
private IEnumerable<Measurement<long>> ObserveStaleCounts() => SnapshotGauge(s => s.Stale);
|
||||
|
||||
private IEnumerable<Measurement<long>> SnapshotGauge(Func<ClusterGaugeState, int> selector)
|
||||
{
|
||||
List<Measurement<long>> results;
|
||||
lock (_gaugeLock)
|
||||
{
|
||||
results = new List<Measurement<long>>(_gaugeState.Count);
|
||||
foreach (var (cluster, state) in _gaugeState)
|
||||
results.Add(new Measurement<long>(selector(state),
|
||||
new KeyValuePair<string, object?>("cluster.id", cluster)));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private readonly record struct ClusterGaugeState(int Primary, int Secondary, int Stale);
|
||||
}
|
||||
213
src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs
Normal file
213
src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pure-function impact preview for UNS structural moves per Phase 6.4 Stream A.2. Given
|
||||
/// a <see cref="UnsMoveOperation"/> plus a snapshot of the draft's UNS tree and its
|
||||
/// equipment + tag counts, returns an <see cref="UnsImpactPreview"/> the Admin UI shows
|
||||
/// in a confirmation modal before committing the move.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Stateless + deterministic — testable without EF or a live draft. The caller
|
||||
/// (Razor page) loads the draft's snapshot via the normal Configuration services, passes
|
||||
/// it in, and the analyzer counts + categorises the impact. The returned
|
||||
/// <see cref="UnsImpactPreview.RevisionToken"/> is the token the caller must re-check at
|
||||
/// confirm time; a mismatch means another operator mutated the draft between preview +
|
||||
/// confirm and the operation needs to be refreshed (decision on concurrent-edit safety
|
||||
/// in Phase 6.4 Scope).</para>
|
||||
///
|
||||
/// <para>Cross-cluster moves are rejected here (decision #82) — equipment is
|
||||
/// cluster-scoped; the UI disables the drop target and surfaces an Export/Import workflow
|
||||
/// toast instead.</para>
|
||||
/// </remarks>
|
||||
public static class UnsImpactAnalyzer
|
||||
{
|
||||
/// <summary>Run the analyzer. Returns a populated preview or throws for invalid operations.</summary>
|
||||
public static UnsImpactPreview Analyze(UnsTreeSnapshot snapshot, UnsMoveOperation move)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
ArgumentNullException.ThrowIfNull(move);
|
||||
|
||||
// Cross-cluster guard — the analyzer refuses rather than silently re-homing.
|
||||
if (!string.Equals(move.SourceClusterId, move.TargetClusterId, StringComparison.OrdinalIgnoreCase))
|
||||
throw new CrossClusterMoveRejectedException(
|
||||
"Equipment is cluster-scoped (decision #82). Use Export → Import to migrate equipment " +
|
||||
"across clusters; drag/drop rejected.");
|
||||
|
||||
return move.Kind switch
|
||||
{
|
||||
UnsMoveKind.LineMove => AnalyzeLineMove(snapshot, move),
|
||||
UnsMoveKind.AreaRename => AnalyzeAreaRename(snapshot, move),
|
||||
UnsMoveKind.LineMerge => AnalyzeLineMerge(snapshot, move),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(move), move.Kind, $"Unsupported move kind {move.Kind}"),
|
||||
};
|
||||
}
|
||||
|
||||
private static UnsImpactPreview AnalyzeLineMove(UnsTreeSnapshot snapshot, UnsMoveOperation move)
|
||||
{
|
||||
var line = snapshot.FindLine(move.SourceLineId!)
|
||||
?? throw new UnsMoveValidationException($"Source line '{move.SourceLineId}' not found in draft {snapshot.DraftGenerationId}.");
|
||||
|
||||
var targetArea = snapshot.FindArea(move.TargetAreaId!)
|
||||
?? throw new UnsMoveValidationException($"Target area '{move.TargetAreaId}' not found in draft {snapshot.DraftGenerationId}.");
|
||||
|
||||
var warnings = new List<string>();
|
||||
if (targetArea.LineIds.Contains(line.LineId, StringComparer.OrdinalIgnoreCase))
|
||||
warnings.Add($"Target area '{targetArea.Name}' already contains line '{line.Name}' — dropping a no-op move.");
|
||||
|
||||
// If the target area has a line with the same display name as the mover, warn about
|
||||
// visual ambiguity even though the IDs differ (operators frequently reuse line names).
|
||||
if (targetArea.LineIds.Any(lid =>
|
||||
snapshot.FindLine(lid) is { } sibling &&
|
||||
string.Equals(sibling.Name, line.Name, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(sibling.LineId, line.LineId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
warnings.Add($"Target area '{targetArea.Name}' already has a line named '{line.Name}'. Consider renaming before the move.");
|
||||
}
|
||||
|
||||
return new UnsImpactPreview
|
||||
{
|
||||
AffectedEquipmentCount = line.EquipmentCount,
|
||||
AffectedTagCount = line.TagCount,
|
||||
CascadeWarnings = warnings,
|
||||
RevisionToken = snapshot.RevisionToken,
|
||||
HumanReadableSummary =
|
||||
$"Moving line '{line.Name}' from area '{snapshot.FindAreaByLineId(line.LineId)?.Name ?? "?"}' " +
|
||||
$"to '{targetArea.Name}' will re-home {line.EquipmentCount} equipment + re-parent {line.TagCount} tags.",
|
||||
};
|
||||
}
|
||||
|
||||
private static UnsImpactPreview AnalyzeAreaRename(UnsTreeSnapshot snapshot, UnsMoveOperation move)
|
||||
{
|
||||
var area = snapshot.FindArea(move.SourceAreaId!)
|
||||
?? throw new UnsMoveValidationException($"Source area '{move.SourceAreaId}' not found in draft {snapshot.DraftGenerationId}.");
|
||||
|
||||
var affectedEquipment = area.LineIds
|
||||
.Select(lid => snapshot.FindLine(lid)?.EquipmentCount ?? 0)
|
||||
.Sum();
|
||||
var affectedTags = area.LineIds
|
||||
.Select(lid => snapshot.FindLine(lid)?.TagCount ?? 0)
|
||||
.Sum();
|
||||
|
||||
return new UnsImpactPreview
|
||||
{
|
||||
AffectedEquipmentCount = affectedEquipment,
|
||||
AffectedTagCount = affectedTags,
|
||||
CascadeWarnings = [],
|
||||
RevisionToken = snapshot.RevisionToken,
|
||||
HumanReadableSummary =
|
||||
$"Renaming area '{area.Name}' → '{move.NewName}' cascades to {area.LineIds.Count} lines / " +
|
||||
$"{affectedEquipment} equipment / {affectedTags} tags.",
|
||||
};
|
||||
}
|
||||
|
||||
private static UnsImpactPreview AnalyzeLineMerge(UnsTreeSnapshot snapshot, UnsMoveOperation move)
|
||||
{
|
||||
var src = snapshot.FindLine(move.SourceLineId!)
|
||||
?? throw new UnsMoveValidationException($"Source line '{move.SourceLineId}' not found.");
|
||||
var dst = snapshot.FindLine(move.TargetLineId!)
|
||||
?? throw new UnsMoveValidationException($"Target line '{move.TargetLineId}' not found.");
|
||||
|
||||
var warnings = new List<string>();
|
||||
if (!string.Equals(snapshot.FindAreaByLineId(src.LineId)?.AreaId,
|
||||
snapshot.FindAreaByLineId(dst.LineId)?.AreaId,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
warnings.Add($"Lines '{src.Name}' and '{dst.Name}' are in different areas. The merge will re-parent equipment + tags into '{dst.Name}'s area.");
|
||||
}
|
||||
|
||||
return new UnsImpactPreview
|
||||
{
|
||||
AffectedEquipmentCount = src.EquipmentCount,
|
||||
AffectedTagCount = src.TagCount,
|
||||
CascadeWarnings = warnings,
|
||||
RevisionToken = snapshot.RevisionToken,
|
||||
HumanReadableSummary =
|
||||
$"Merging line '{src.Name}' into '{dst.Name}': {src.EquipmentCount} equipment + {src.TagCount} tags re-parent. " +
|
||||
$"The source line is deleted at commit.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Kind of UNS structural move the analyzer understands.</summary>
|
||||
public enum UnsMoveKind
|
||||
{
|
||||
/// <summary>Drag a whole line from one area to another.</summary>
|
||||
LineMove,
|
||||
|
||||
/// <summary>Rename an area (cascades to the UNS paths of every equipment + tag below it).</summary>
|
||||
AreaRename,
|
||||
|
||||
/// <summary>Merge two lines into one; source line's equipment + tags are re-parented.</summary>
|
||||
LineMerge,
|
||||
}
|
||||
|
||||
/// <summary>One UNS structural move request.</summary>
|
||||
/// <param name="Kind">Move variant — selects which source + target fields are required.</param>
|
||||
/// <param name="SourceClusterId">Cluster of the source node. Must match <see cref="TargetClusterId"/> (decision #82).</param>
|
||||
/// <param name="TargetClusterId">Cluster of the target node.</param>
|
||||
/// <param name="SourceAreaId">Source area id for <see cref="UnsMoveKind.AreaRename"/>.</param>
|
||||
/// <param name="SourceLineId">Source line id for <see cref="UnsMoveKind.LineMove"/> / <see cref="UnsMoveKind.LineMerge"/>.</param>
|
||||
/// <param name="TargetAreaId">Target area id for <see cref="UnsMoveKind.LineMove"/>.</param>
|
||||
/// <param name="TargetLineId">Target line id for <see cref="UnsMoveKind.LineMerge"/>.</param>
|
||||
/// <param name="NewName">New display name for <see cref="UnsMoveKind.AreaRename"/>.</param>
|
||||
public sealed record UnsMoveOperation(
|
||||
UnsMoveKind Kind,
|
||||
string SourceClusterId,
|
||||
string TargetClusterId,
|
||||
string? SourceAreaId = null,
|
||||
string? SourceLineId = null,
|
||||
string? TargetAreaId = null,
|
||||
string? TargetLineId = null,
|
||||
string? NewName = null);
|
||||
|
||||
/// <summary>Snapshot of the UNS tree + counts the analyzer walks.</summary>
|
||||
public sealed class UnsTreeSnapshot
|
||||
{
|
||||
public required long DraftGenerationId { get; init; }
|
||||
public required DraftRevisionToken RevisionToken { get; init; }
|
||||
public required IReadOnlyList<UnsAreaSummary> Areas { get; init; }
|
||||
public required IReadOnlyList<UnsLineSummary> Lines { get; init; }
|
||||
|
||||
public UnsAreaSummary? FindArea(string areaId) =>
|
||||
Areas.FirstOrDefault(a => string.Equals(a.AreaId, areaId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public UnsLineSummary? FindLine(string lineId) =>
|
||||
Lines.FirstOrDefault(l => string.Equals(l.LineId, lineId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public UnsAreaSummary? FindAreaByLineId(string lineId) =>
|
||||
Areas.FirstOrDefault(a => a.LineIds.Contains(lineId, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public sealed record UnsAreaSummary(string AreaId, string Name, IReadOnlyList<string> LineIds);
|
||||
|
||||
public sealed record UnsLineSummary(string LineId, string Name, int EquipmentCount, int TagCount);
|
||||
|
||||
/// <summary>
|
||||
/// Opaque per-draft revision fingerprint. Preview fetches the current token + stores it
|
||||
/// in the <see cref="UnsImpactPreview.RevisionToken"/>. Confirm compares the token against
|
||||
/// the draft's live value; mismatch means another operator mutated the draft between
|
||||
/// preview + commit — raise <c>409 Conflict / refresh-required</c> in the UI.
|
||||
/// </summary>
|
||||
public sealed record DraftRevisionToken(string Value)
|
||||
{
|
||||
/// <summary>Compare two tokens for equality; null-safe.</summary>
|
||||
public bool Matches(DraftRevisionToken? other) =>
|
||||
other is not null &&
|
||||
string.Equals(Value, other.Value, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Output of <see cref="UnsImpactAnalyzer.Analyze"/>.</summary>
|
||||
public sealed class UnsImpactPreview
|
||||
{
|
||||
public required int AffectedEquipmentCount { get; init; }
|
||||
public required int AffectedTagCount { get; init; }
|
||||
public required IReadOnlyList<string> CascadeWarnings { get; init; }
|
||||
public required DraftRevisionToken RevisionToken { get; init; }
|
||||
public required string HumanReadableSummary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Thrown when a move targets a different cluster than the source (decision #82).</summary>
|
||||
public sealed class CrossClusterMoveRejectedException(string message) : Exception(message);
|
||||
|
||||
/// <summary>Thrown when the move operation references a source / target that doesn't exist in the draft.</summary>
|
||||
public sealed class UnsMoveValidationException(string message) : Exception(message);
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
@@ -47,4 +49,132 @@ public sealed class UnsService(OtOpcUaConfigDbContext db)
|
||||
await db.SaveChangesAsync(ct);
|
||||
return line;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the full UNS tree snapshot for the analyzer. Walks areas + lines in the draft
|
||||
/// and counts equipment + tags per line. Returns the snapshot plus a deterministic
|
||||
/// revision token computed by SHA-256'ing the sorted (kind, id, parent, name) tuples —
|
||||
/// stable across processes + changes whenever any row is added / modified / deleted.
|
||||
/// </summary>
|
||||
public async Task<UnsTreeSnapshot> LoadSnapshotAsync(long generationId, CancellationToken ct)
|
||||
{
|
||||
var areas = await db.UnsAreas.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.UnsAreaId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var lines = await db.UnsLines.AsNoTracking()
|
||||
.Where(l => l.GenerationId == generationId)
|
||||
.OrderBy(l => l.UnsLineId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var equipmentCounts = await db.Equipment.AsNoTracking()
|
||||
.Where(e => e.GenerationId == generationId)
|
||||
.GroupBy(e => e.UnsLineId)
|
||||
.Select(g => new { LineId = g.Key, Count = g.Count() })
|
||||
.ToListAsync(ct);
|
||||
var equipmentByLine = equipmentCounts.ToDictionary(x => x.LineId, x => x.Count, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var lineSummaries = lines.Select(l =>
|
||||
new UnsLineSummary(
|
||||
LineId: l.UnsLineId,
|
||||
Name: l.Name,
|
||||
EquipmentCount: equipmentByLine.GetValueOrDefault(l.UnsLineId),
|
||||
TagCount: 0)).ToList();
|
||||
|
||||
var areaSummaries = areas.Select(a =>
|
||||
new UnsAreaSummary(
|
||||
AreaId: a.UnsAreaId,
|
||||
Name: a.Name,
|
||||
LineIds: lines.Where(l => string.Equals(l.UnsAreaId, a.UnsAreaId, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(l => l.UnsLineId).ToList())).ToList();
|
||||
|
||||
return new UnsTreeSnapshot
|
||||
{
|
||||
DraftGenerationId = generationId,
|
||||
RevisionToken = ComputeRevisionToken(areas, lines),
|
||||
Areas = areaSummaries,
|
||||
Lines = lineSummaries,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomic re-parent of a line to a new area inside the same draft. The caller must pass
|
||||
/// the revision token it observed at preview time — a mismatch raises
|
||||
/// <see cref="DraftRevisionConflictException"/> so the UI can show the 409 concurrent-edit
|
||||
/// modal instead of silently overwriting a peer's work.
|
||||
/// </summary>
|
||||
public async Task MoveLineAsync(
|
||||
long generationId,
|
||||
DraftRevisionToken expected,
|
||||
string lineId,
|
||||
string targetAreaId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expected);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(lineId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetAreaId);
|
||||
|
||||
var supportsTx = db.Database.IsRelational();
|
||||
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
|
||||
if (supportsTx) tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var areas = await db.UnsAreas
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.UnsAreaId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var lines = await db.UnsLines
|
||||
.Where(l => l.GenerationId == generationId)
|
||||
.OrderBy(l => l.UnsLineId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var current = ComputeRevisionToken(areas, lines);
|
||||
if (!current.Matches(expected))
|
||||
throw new DraftRevisionConflictException(
|
||||
$"Draft {generationId} changed since preview. Expected revision {expected.Value}, saw {current.Value}. " +
|
||||
"Refresh + redo the move.");
|
||||
|
||||
var line = lines.FirstOrDefault(l => string.Equals(l.UnsLineId, lineId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException($"Line '{lineId}' not found in draft {generationId}.");
|
||||
|
||||
if (!areas.Any(a => string.Equals(a.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException($"Target area '{targetAreaId}' not found in draft {generationId}.");
|
||||
|
||||
if (string.Equals(line.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase))
|
||||
return; // no-op drop — same area
|
||||
|
||||
line.UnsAreaId = targetAreaId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (tx is not null) await tx.RollbackAsync(ct).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tx is not null) await tx.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static DraftRevisionToken ComputeRevisionToken(IReadOnlyList<UnsArea> areas, IReadOnlyList<UnsLine> lines)
|
||||
{
|
||||
var sb = new StringBuilder(capacity: 256 + (areas.Count + lines.Count) * 80);
|
||||
foreach (var a in areas.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal))
|
||||
sb.Append("A:").Append(a.UnsAreaId).Append('|').Append(a.Name).Append('|').Append(a.Notes ?? "").Append(';');
|
||||
foreach (var l in lines.OrderBy(l => l.UnsLineId, StringComparer.Ordinal))
|
||||
sb.Append("L:").Append(l.UnsLineId).Append('|').Append(l.UnsAreaId).Append('|').Append(l.Name).Append('|').Append(l.Notes ?? "").Append(';');
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
return new DraftRevisionToken(Convert.ToHexStringLower(hash)[..16]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thrown when a UNS move's expected revision token no longer matches the live draft
|
||||
/// — another operator mutated the draft between preview + commit. Caller surfaces a 409-style
|
||||
/// "refresh required" modal in the Admin UI.</summary>
|
||||
public sealed class DraftRevisionConflictException(string message) : Exception(message);
|
||||
|
||||
@@ -16,10 +16,13 @@
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0"/>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.2"/>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.15.2-beta.1"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -23,5 +23,10 @@
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": "Information"
|
||||
},
|
||||
"Metrics": {
|
||||
"Prometheus": {
|
||||
"Enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
src/ZB.MOM.WW.OtOpcUa.Analyzers/AnalyzerReleases.Shipped.md
Normal file
10
src/ZB.MOM.WW.OtOpcUa.Analyzers/AnalyzerReleases.Shipped.md
Normal file
@@ -0,0 +1,10 @@
|
||||
; Shipped analyzer releases.
|
||||
; See https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
|
||||
|
||||
## Release 1.0
|
||||
|
||||
### New Rules
|
||||
|
||||
Rule ID | Category | Severity | Notes
|
||||
--------|----------|----------|-------
|
||||
OTOPCUA0001 | OtOpcUa.Resilience | Warning | Direct driver-capability call bypasses CapabilityInvoker
|
||||
@@ -0,0 +1,2 @@
|
||||
; Unshipped analyzer release.
|
||||
; See https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using Microsoft.CodeAnalysis.Operations;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Analyzers;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic analyzer that flags direct invocations of Phase 6.1-wrapped driver-capability
|
||||
/// methods when the call is NOT already running inside a <c>CapabilityInvoker.ExecuteAsync</c>,
|
||||
/// <c>CapabilityInvoker.ExecuteWriteAsync</c>, or <c>AlarmSurfaceInvoker.*Async</c> lambda.
|
||||
/// The wrapping is what gives us per-host breaker isolation, retry semantics, bulkhead-depth
|
||||
/// accounting, and alarm-ack idempotence guards — raw calls bypass all of that.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The analyzer matches by receiver-interface identity using Roslyn's semantic model, not by
|
||||
/// method name, so a driver with an unusually-named method implementing <c>IReadable.ReadAsync</c>
|
||||
/// still trips the rule. Lambda-context detection walks up the syntax tree from the call site
|
||||
/// + checks whether any enclosing <c>InvocationExpressionSyntax</c> targets a member whose
|
||||
/// containing type is <c>CapabilityInvoker</c> or <c>AlarmSurfaceInvoker</c>. The rule is
|
||||
/// intentionally narrow: it does NOT try to enforce the capability argument matches the
|
||||
/// method (e.g. ReadAsync wrapped in <c>ExecuteAsync(DriverCapability.Write, ...)</c> still
|
||||
/// passes) — that'd require flow analysis beyond single-expression scope.
|
||||
/// </remarks>
|
||||
[DiagnosticAnalyzer(Microsoft.CodeAnalysis.LanguageNames.CSharp)]
|
||||
public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer
|
||||
{
|
||||
public const string DiagnosticId = "OTOPCUA0001";
|
||||
|
||||
/// <summary>Interfaces whose methods must be called through the capability invoker.</summary>
|
||||
private static readonly string[] GuardedInterfaces =
|
||||
[
|
||||
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable",
|
||||
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IWritable",
|
||||
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.ITagDiscovery",
|
||||
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.ISubscribable",
|
||||
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHostConnectivityProbe",
|
||||
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IAlarmSource",
|
||||
"ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHistoryProvider",
|
||||
];
|
||||
|
||||
/// <summary>Wrapper types whose lambda arguments are the allowed home for guarded calls.</summary>
|
||||
private static readonly string[] WrapperTypes =
|
||||
[
|
||||
"ZB.MOM.WW.OtOpcUa.Core.Resilience.CapabilityInvoker",
|
||||
"ZB.MOM.WW.OtOpcUa.Core.Resilience.AlarmSurfaceInvoker",
|
||||
];
|
||||
|
||||
private static readonly DiagnosticDescriptor Rule = new(
|
||||
id: DiagnosticId,
|
||||
title: "Driver capability call must be wrapped in CapabilityInvoker",
|
||||
messageFormat: "Call to '{0}' is not wrapped in CapabilityInvoker.ExecuteAsync / ExecuteWriteAsync / AlarmSurfaceInvoker.*. Without the wrapping, Phase 6.1 resilience (retry, breaker, bulkhead, tracker telemetry) is bypassed for this call.",
|
||||
category: "OtOpcUa.Resilience",
|
||||
defaultSeverity: DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true,
|
||||
description: "Phase 6.1 Stream A requires every IReadable/IWritable/ITagDiscovery/ISubscribable/IHostConnectivityProbe/IAlarmSource/IHistoryProvider call to route through the shared Polly pipeline. Direct calls skip the pipeline + lose per-host isolation, retry semantics, and telemetry. If the caller is Core/Server/Driver dispatch code, wrap the call in CapabilityInvoker.ExecuteAsync. If the caller is a unit test invoking the driver directly to test its wire-level behavior, either suppress with a pragma or move the suppression into a NoWarn for the test project.");
|
||||
|
||||
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(Rule);
|
||||
|
||||
public override void Initialize(AnalysisContext context)
|
||||
{
|
||||
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
|
||||
context.EnableConcurrentExecution();
|
||||
context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
|
||||
}
|
||||
|
||||
private static void AnalyzeInvocation(OperationAnalysisContext context)
|
||||
{
|
||||
var invocation = (Microsoft.CodeAnalysis.Operations.IInvocationOperation)context.Operation;
|
||||
var method = invocation.TargetMethod;
|
||||
|
||||
// Narrow the rule to async wire calls. Synchronous accessors like
|
||||
// IHostConnectivityProbe.GetHostStatuses() are pure in-memory snapshots + would never
|
||||
// benefit from the Polly pipeline; flagging them just creates false-positives.
|
||||
if (!IsAsyncReturningType(method.ReturnType)) return;
|
||||
if (!ImplementsGuardedInterface(method)) return;
|
||||
if (IsInsideWrapperLambda(invocation.Syntax, context.Operation.SemanticModel, context.CancellationToken)) return;
|
||||
|
||||
var diag = Diagnostic.Create(Rule, invocation.Syntax.GetLocation(), $"{method.ContainingType.Name}.{method.Name}");
|
||||
context.ReportDiagnostic(diag);
|
||||
}
|
||||
|
||||
private static bool IsAsyncReturningType(ITypeSymbol type)
|
||||
{
|
||||
var name = type.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
|
||||
return name is "global::System.Threading.Tasks.Task"
|
||||
or "global::System.Threading.Tasks.Task<TResult>"
|
||||
or "global::System.Threading.Tasks.ValueTask"
|
||||
or "global::System.Threading.Tasks.ValueTask<TResult>";
|
||||
}
|
||||
|
||||
private static bool ImplementsGuardedInterface(IMethodSymbol method)
|
||||
{
|
||||
foreach (var iface in method.ContainingType.AllInterfaces.Concat(new[] { method.ContainingType }))
|
||||
{
|
||||
var ifaceFqn = iface.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
|
||||
.Replace("global::", string.Empty);
|
||||
if (!GuardedInterfaces.Contains(ifaceFqn)) continue;
|
||||
|
||||
foreach (var member in iface.GetMembers().OfType<IMethodSymbol>())
|
||||
{
|
||||
var impl = method.ContainingType.FindImplementationForInterfaceMember(member);
|
||||
if (SymbolEqualityComparer.Default.Equals(impl, method) ||
|
||||
SymbolEqualityComparer.Default.Equals(method.OriginalDefinition, member))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsInsideWrapperLambda(SyntaxNode startNode, SemanticModel? semanticModel, System.Threading.CancellationToken ct)
|
||||
{
|
||||
if (semanticModel is null) return false;
|
||||
|
||||
for (var node = startNode.Parent; node is not null; node = node.Parent)
|
||||
{
|
||||
// We only care about an enclosing invocation — the call we're auditing must literally
|
||||
// live inside a lambda (ParenthesizedLambda / SimpleLambda / AnonymousMethod) that is
|
||||
// an argument of a CapabilityInvoker.Execute* / AlarmSurfaceInvoker.* call.
|
||||
if (node is not InvocationExpressionSyntax outer) continue;
|
||||
|
||||
var sym = semanticModel.GetSymbolInfo(outer, ct).Symbol as IMethodSymbol;
|
||||
if (sym is null) continue;
|
||||
|
||||
var outerTypeFqn = sym.ContainingType.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
|
||||
.Replace("global::", string.Empty);
|
||||
if (!WrapperTypes.Contains(outerTypeFqn)) continue;
|
||||
|
||||
// The call is wrapped IFF our startNode is transitively inside one of the outer
|
||||
// call's argument lambdas. Walk the outer invocation's argument list + check whether
|
||||
// any lambda body contains the startNode's position.
|
||||
foreach (var arg in outer.ArgumentList.Arguments)
|
||||
{
|
||||
if (arg.Expression is not AnonymousFunctionExpressionSyntax lambda) continue;
|
||||
if (lambda.Span.Contains(startNode.Span)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Roslyn analyzers ship as netstandard2.0 so they load into the MSBuild compiler host
|
||||
(which on .NET Framework 4.7.2 and .NET 6+ equally resolves netstandard2.0). -->
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsRoslynComponent>true</IsRoslynComponent>
|
||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Analyzers</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" PrivateAssets="all"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="AnalyzerReleases.Shipped.md"/>
|
||||
<AdditionalFiles Include="AnalyzerReleases.Unshipped.md"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -9,7 +9,7 @@ return await new CliApplicationBuilder()
|
||||
if (type.IsSubclassOf(typeof(CommandBase))) return Activator.CreateInstance(type, CommandBase.DefaultFactory)!;
|
||||
return Activator.CreateInstance(type)!;
|
||||
})
|
||||
.SetExecutableName("lmxopcua-cli")
|
||||
.SetDescription("LmxOpcUa CLI - command-line client for the LmxOpcUa OPC UA server")
|
||||
.SetExecutableName("otopcua-cli")
|
||||
.SetDescription("OtOpcUa CLI - command-line client for the OtOpcUa OPC UA server")
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
@@ -18,8 +18,8 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
|
||||
|
||||
var config = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "LmxOpcUaClient",
|
||||
ApplicationUri = "urn:localhost:LmxOpcUaClient",
|
||||
ApplicationName = "OtOpcUaClient",
|
||||
ApplicationUri = "urn:localhost:OtOpcUaClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
@@ -60,7 +60,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
|
||||
{
|
||||
var app = new ApplicationInstance
|
||||
{
|
||||
ApplicationName = "LmxOpcUaClient",
|
||||
ApplicationName = "OtOpcUaClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
ApplicationConfiguration = config
|
||||
};
|
||||
|
||||
90
src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs
Normal file
90
src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the canonical under-LocalAppData folder for the shared OPC UA client's PKI
|
||||
/// store + persisted settings. Renamed from <c>LmxOpcUaClient</c> to <c>OtOpcUaClient</c>
|
||||
/// in task #208; a one-shot migration shim moves a pre-rename folder in place on first
|
||||
/// resolution so existing developer boxes keep their trusted server certs + saved
|
||||
/// connection settings on upgrade.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Thread-safe: the rename uses <see cref="Directory.Move"/> which is atomic on NTFS
|
||||
/// within the same volume. The lock guarantees the migration runs at most once per
|
||||
/// process even under concurrent first-touch from CLI + UI.
|
||||
/// </remarks>
|
||||
public static class ClientStoragePaths
|
||||
{
|
||||
/// <summary>Canonical client folder name. Post-#208.</summary>
|
||||
public const string CanonicalFolderName = "OtOpcUaClient";
|
||||
|
||||
/// <summary>Pre-#208 folder name. Used only by the migration shim.</summary>
|
||||
public const string LegacyFolderName = "LmxOpcUaClient";
|
||||
|
||||
private static readonly Lock _migrationLock = new();
|
||||
private static bool _migrationChecked;
|
||||
|
||||
/// <summary>
|
||||
/// Absolute path to the client's top-level folder under LocalApplicationData. Runs the
|
||||
/// one-shot legacy-folder migration before returning so callers that depend on this
|
||||
/// path (PKI store, settings file) find their existing state at the canonical name.
|
||||
/// </summary>
|
||||
public static string GetRoot()
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var canonical = Path.Combine(localAppData, CanonicalFolderName);
|
||||
MigrateLegacyFolderIfNeeded(localAppData, canonical);
|
||||
return canonical;
|
||||
}
|
||||
|
||||
/// <summary>Subfolder for the application's PKI store — used by both CLI + UI.</summary>
|
||||
public static string GetPkiPath() => Path.Combine(GetRoot(), "pki");
|
||||
|
||||
/// <summary>
|
||||
/// Expose the migration probe for tests + for callers that want to check whether the
|
||||
/// legacy folder still exists without forcing the rename. Returns true when a legacy
|
||||
/// folder existed + was moved to canonical, false when no migration was needed or
|
||||
/// canonical was already present.
|
||||
/// </summary>
|
||||
public static bool TryRunLegacyMigration()
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var canonical = Path.Combine(localAppData, CanonicalFolderName);
|
||||
return MigrateLegacyFolderIfNeeded(localAppData, canonical);
|
||||
}
|
||||
|
||||
private static bool MigrateLegacyFolderIfNeeded(string localAppData, string canonical)
|
||||
{
|
||||
// Fast-path out of the lock when the migration has already been attempted this process
|
||||
// — saves the IO on every subsequent call, + the migration is idempotent within the
|
||||
// same process anyway.
|
||||
if (_migrationChecked) return false;
|
||||
|
||||
lock (_migrationLock)
|
||||
{
|
||||
if (_migrationChecked) return false;
|
||||
_migrationChecked = true;
|
||||
|
||||
var legacy = Path.Combine(localAppData, LegacyFolderName);
|
||||
|
||||
// Only migrate when the legacy folder is present + canonical isn't. Either of the
|
||||
// other three combinations (neither / only-canonical / both) means migration
|
||||
// should NOT run: no-op fresh install, already-migrated, or manual state the
|
||||
// developer has set up — don't clobber.
|
||||
if (!Directory.Exists(legacy)) return false;
|
||||
if (Directory.Exists(canonical)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Move(legacy, canonical);
|
||||
return true;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Concurrent another-process-moved-it or volume-boundary or permissions — leave
|
||||
// the legacy folder alone; callers that need it can either re-run migration
|
||||
// manually or point CertificateStorePath explicitly.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,11 @@ public sealed class ConnectionSettings
|
||||
public bool AutoAcceptCertificates { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData.
|
||||
/// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData
|
||||
/// resolved via <see cref="ClientStoragePaths"/> so the one-shot legacy-folder migration
|
||||
/// runs before the path is returned.
|
||||
/// </summary>
|
||||
public string CertificateStorePath { get; set; } = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LmxOpcUaClient", "pki");
|
||||
public string CertificateStorePath { get; set; } = ClientStoragePaths.GetPkiPath();
|
||||
|
||||
/// <summary>
|
||||
/// Validates the settings and throws if any required values are missing or invalid.
|
||||
|
||||
@@ -425,7 +425,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
: new UserIdentity();
|
||||
|
||||
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
|
||||
return await _sessionFactory.CreateSessionAsync(config, endpoint, "LmxOpcUaClient", sessionTimeoutMs, identity,
|
||||
return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity,
|
||||
ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
||||
|
||||
@@ -7,9 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
||||
/// </summary>
|
||||
public sealed class JsonSettingsService : ISettingsService
|
||||
{
|
||||
private static readonly string SettingsDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LmxOpcUaClient");
|
||||
// ClientStoragePaths.GetRoot runs the one-shot legacy-folder migration so pre-#208
|
||||
// developer boxes pick up their existing settings.json on first launch post-rename.
|
||||
private static readonly string SettingsDir = ClientStoragePaths.GetRoot();
|
||||
|
||||
private static readonly string SettingsPath = Path.Combine(SettingsDir, "settings.json");
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
|
||||
[ObservableProperty] private bool _autoAcceptCertificates = true;
|
||||
|
||||
[ObservableProperty] private string _certificateStorePath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LmxOpcUaClient", "pki");
|
||||
[ObservableProperty] private string _certificateStorePath = ClientStoragePaths.GetPkiPath();
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(ConnectCommand))]
|
||||
|
||||
@@ -27,6 +27,24 @@ public sealed class DriverInstance
|
||||
/// <summary>Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91).</summary>
|
||||
public required string DriverConfig { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-instance overrides for the Phase 6.1 shared Polly resilience pipeline.
|
||||
/// Null = use the driver's tier defaults (decision #143). When populated, expected shape:
|
||||
/// <code>
|
||||
/// {
|
||||
/// "bulkheadMaxConcurrent": 16,
|
||||
/// "bulkheadMaxQueue": 64,
|
||||
/// "capabilityPolicies": {
|
||||
/// "Read": { "timeoutSeconds": 5, "retryCount": 5, "breakerFailureThreshold": 3 },
|
||||
/// "Write": { "timeoutSeconds": 5, "retryCount": 0, "breakerFailureThreshold": 5 }
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// Parsed at startup by <c>DriverResilienceOptionsParser</c>; every key is optional +
|
||||
/// unrecognised keys are ignored so future shapes land without a migration.
|
||||
/// </summary>
|
||||
public string? ResilienceConfig { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Staged equipment-import batch per Phase 6.4 Stream B.2. Rows land in the child
|
||||
/// <see cref="EquipmentImportRow"/> table under a batch header; operator reviews + either
|
||||
/// drops (via <c>DropImportBatch</c>) or finalises (via <c>FinaliseImportBatch</c>) in one
|
||||
/// bounded transaction. The live <c>Equipment</c> table never sees partial state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>User-scoped visibility: the preview modal only shows batches where
|
||||
/// <see cref="CreatedBy"/> equals the current operator. Prevents accidental
|
||||
/// cross-operator finalise during concurrent imports. An admin finalise / drop surface
|
||||
/// can override this — tracked alongside the UI follow-up.</para>
|
||||
///
|
||||
/// <para><see cref="FinalisedAtUtc"/> stamps the moment the batch promoted from staging
|
||||
/// into <c>Equipment</c>. Null = still in staging; non-null = archived / finalised.</para>
|
||||
/// </remarks>
|
||||
public sealed class EquipmentImportBatch
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public required string ClusterId { get; set; }
|
||||
public required string CreatedBy { get; set; }
|
||||
public DateTime CreatedAtUtc { get; set; }
|
||||
public int RowsStaged { get; set; }
|
||||
public int RowsAccepted { get; set; }
|
||||
public int RowsRejected { get; set; }
|
||||
public DateTime? FinalisedAtUtc { get; set; }
|
||||
|
||||
public ICollection<EquipmentImportRow> Rows { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One staged row under an <see cref="EquipmentImportBatch"/>. Mirrors the decision #117
|
||||
/// + decision #139 columns from the CSV importer's output + an
|
||||
/// <see cref="IsAccepted"/> flag + a <see cref="RejectReason"/> string the preview modal
|
||||
/// renders.
|
||||
/// </summary>
|
||||
public sealed class EquipmentImportRow
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid BatchId { get; set; }
|
||||
public int LineNumberInFile { get; set; }
|
||||
public bool IsAccepted { get; set; }
|
||||
public string? RejectReason { get; set; }
|
||||
|
||||
// Required (decision #117)
|
||||
public required string ZTag { get; set; }
|
||||
public required string MachineCode { get; set; }
|
||||
public required string SAPID { get; set; }
|
||||
public required string EquipmentId { get; set; }
|
||||
public required string EquipmentUuid { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string UnsAreaName { get; set; }
|
||||
public required string UnsLineName { get; set; }
|
||||
|
||||
// Optional (decision #139 — OPC 40010 Identification)
|
||||
public string? Manufacturer { get; set; }
|
||||
public string? Model { get; set; }
|
||||
public string? SerialNumber { get; set; }
|
||||
public string? HardwareRevision { get; set; }
|
||||
public string? SoftwareRevision { get; set; }
|
||||
public string? YearOfConstruction { get; set; }
|
||||
public string? AssetLocation { get; set; }
|
||||
public string? ManufacturerUri { get; set; }
|
||||
public string? DeviceManualUri { get; set; }
|
||||
|
||||
public EquipmentImportBatch? Batch { get; set; }
|
||||
}
|
||||
1347
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.Designer.cs
generated
Normal file
1347
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDriverInstanceResilienceConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ResilienceConfig",
|
||||
table: "DriverInstance",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "CK_DriverInstance_ResilienceConfig_IsJson",
|
||||
table: "DriverInstance",
|
||||
sql: "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "CK_DriverInstance_ResilienceConfig_IsJson",
|
||||
table: "DriverInstance");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ResilienceConfig",
|
||||
table: "DriverInstance");
|
||||
}
|
||||
}
|
||||
}
|
||||
1505
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419185124_AddEquipmentImportBatch.Designer.cs
generated
Normal file
1505
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419185124_AddEquipmentImportBatch.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEquipmentImportBatch : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EquipmentImportBatch",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
|
||||
RowsStaged = table.Column<int>(type: "int", nullable: false),
|
||||
RowsAccepted = table.Column<int>(type: "int", nullable: false),
|
||||
RowsRejected = table.Column<int>(type: "int", nullable: false),
|
||||
FinalisedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EquipmentImportBatch", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EquipmentImportRow",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
BatchId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
LineNumberInFile = table.Column<int>(type: "int", nullable: false),
|
||||
IsAccepted = table.Column<bool>(type: "bit", nullable: false),
|
||||
RejectReason = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||
ZTag = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
MachineCode = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
SAPID = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
EquipmentUuid = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
UnsAreaName = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
UnsLineName = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Manufacturer = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
Model = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
SerialNumber = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
HardwareRevision = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
SoftwareRevision = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
YearOfConstruction = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: true),
|
||||
AssetLocation = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||
ManufacturerUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||
DeviceManualUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EquipmentImportRow", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_EquipmentImportRow_EquipmentImportBatch_BatchId",
|
||||
column: x => x.BatchId,
|
||||
principalTable: "EquipmentImportBatch",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EquipmentImportBatch_Creator_Finalised",
|
||||
table: "EquipmentImportBatch",
|
||||
columns: new[] { "CreatedBy", "FinalisedAtUtc" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EquipmentImportRow_Batch",
|
||||
table: "EquipmentImportRow",
|
||||
column: "BatchId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "EquipmentImportRow");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "EquipmentImportBatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Extends <c>dbo.sp_ComputeGenerationDiff</c> to emit <c>NodeAcl</c> rows alongside the
|
||||
/// existing Namespace/DriverInstance/Equipment/Tag output — closes the final slice of
|
||||
/// task #196 (DiffViewer ACL section). Logical id for NodeAcl is a composite
|
||||
/// <c>LdapGroup|ScopeKind|ScopeId</c> triple so a Change row surfaces whether the grant
|
||||
/// shifted permissions, moved scope, or was added/removed outright.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public partial class ExtendComputeGenerationDiffWithNodeAcl : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(Procs.ComputeGenerationDiffV2);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(Procs.ComputeGenerationDiffV1);
|
||||
}
|
||||
|
||||
private static class Procs
|
||||
{
|
||||
/// <summary>V2 — adds the NodeAcl section to the diff output.</summary>
|
||||
public const string ComputeGenerationDiffV2 = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||
@FromGenerationId bigint,
|
||||
@ToGenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
|
||||
|
||||
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
-- NodeAcl section. Logical id is the (LdapGroup, ScopeKind, ScopeId) triple so the diff
|
||||
-- distinguishes same row with new permissions (Modified via CHECKSUM on PermissionFlags + Notes)
|
||||
-- from a scope move (which surfaces as Added + Removed of different logical ids).
|
||||
WITH f AS (
|
||||
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
|
||||
t AS (
|
||||
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||
DROP TABLE #diff;
|
||||
END
|
||||
";
|
||||
|
||||
/// <summary>V1 — exact proc shipped in migration 20260417215224_StoredProcedures. Restored on Down().</summary>
|
||||
public const string ComputeGenerationDiffV1 = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||
@FromGenerationId bigint,
|
||||
@ToGenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(64), ChangeKind nvarchar(16));
|
||||
|
||||
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Namespace', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'DriverInstance', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Equipment', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Tag', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||
DROP TABLE #diff;
|
||||
END
|
||||
";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,6 +413,9 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("ResilienceConfig")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("DriverInstanceRowId");
|
||||
|
||||
b.HasIndex("ClusterId");
|
||||
@@ -431,6 +434,8 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
b.ToTable("DriverInstance", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1");
|
||||
|
||||
t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson", "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -599,6 +604,148 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
b.ToTable("Equipment", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("ClusterId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("datetime2(3)");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<DateTime?>("FinalisedAtUtc")
|
||||
.HasColumnType("datetime2(3)");
|
||||
|
||||
b.Property<int>("RowsAccepted")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("RowsRejected")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("RowsStaged")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedBy", "FinalisedAtUtc")
|
||||
.HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised");
|
||||
|
||||
b.ToTable("EquipmentImportBatch", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("AssetLocation")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<Guid>("BatchId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("DeviceManualUri")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<string>("EquipmentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("EquipmentUuid")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("HardwareRevision")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<bool>("IsAccepted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int>("LineNumberInFile")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("MachineCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Manufacturer")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("ManufacturerUri")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("RejectReason")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<string>("SAPID")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("SerialNumber")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("SoftwareRevision")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("UnsAreaName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("UnsLineName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("YearOfConstruction")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("ZTag")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BatchId")
|
||||
.HasDatabaseName("IX_EquipmentImportRow_Batch");
|
||||
|
||||
b.ToTable("EquipmentImportRow", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b =>
|
||||
{
|
||||
b.Property<Guid>("ReservationId")
|
||||
@@ -1226,6 +1373,17 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
b.Navigation("Generation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b =>
|
||||
{
|
||||
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", "Batch")
|
||||
.WithMany("Rows")
|
||||
.HasForeignKey("BatchId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Batch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b =>
|
||||
{
|
||||
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
|
||||
@@ -1325,6 +1483,11 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
b.Navigation("GenerationState");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b =>
|
||||
{
|
||||
b.Navigation("Rows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b =>
|
||||
{
|
||||
b.Navigation("Generations");
|
||||
|
||||
@@ -30,6 +30,8 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
public DbSet<DriverHostStatus> DriverHostStatuses => Set<DriverHostStatus>();
|
||||
public DbSet<DriverInstanceResilienceStatus> DriverInstanceResilienceStatuses => Set<DriverInstanceResilienceStatus>();
|
||||
public DbSet<LdapGroupRoleMapping> LdapGroupRoleMappings => Set<LdapGroupRoleMapping>();
|
||||
public DbSet<EquipmentImportBatch> EquipmentImportBatches => Set<EquipmentImportBatch>();
|
||||
public DbSet<EquipmentImportRow> EquipmentImportRows => Set<EquipmentImportRow>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -53,6 +55,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
ConfigureDriverHostStatus(modelBuilder);
|
||||
ConfigureDriverInstanceResilienceStatus(modelBuilder);
|
||||
ConfigureLdapGroupRoleMapping(modelBuilder);
|
||||
ConfigureEquipmentImportBatch(modelBuilder);
|
||||
}
|
||||
|
||||
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
|
||||
@@ -251,6 +254,8 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
{
|
||||
t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson",
|
||||
"ISJSON(DriverConfig) = 1");
|
||||
t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson",
|
||||
"ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1");
|
||||
});
|
||||
e.HasKey(x => x.DriverInstanceRowId);
|
||||
e.Property(x => x.DriverInstanceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
@@ -260,6 +265,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.DriverType).HasMaxLength(32);
|
||||
e.Property(x => x.DriverConfig).HasColumnType("nvarchar(max)");
|
||||
e.Property(x => x.ResilienceConfig).HasColumnType("nvarchar(max)");
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
|
||||
@@ -565,4 +571,52 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
e.HasIndex(x => x.LdapGroup).HasDatabaseName("IX_LdapGroupRoleMapping_Group");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureEquipmentImportBatch(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<EquipmentImportBatch>(e =>
|
||||
{
|
||||
e.ToTable("EquipmentImportBatch");
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.CreatedBy).HasMaxLength(128);
|
||||
e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.FinalisedAtUtc).HasColumnType("datetime2(3)");
|
||||
|
||||
// Admin preview modal filters by user; finalise / drop both hit this index.
|
||||
e.HasIndex(x => new { x.CreatedBy, x.FinalisedAtUtc })
|
||||
.HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<EquipmentImportRow>(e =>
|
||||
{
|
||||
e.ToTable("EquipmentImportRow");
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.ZTag).HasMaxLength(128);
|
||||
e.Property(x => x.MachineCode).HasMaxLength(128);
|
||||
e.Property(x => x.SAPID).HasMaxLength(128);
|
||||
e.Property(x => x.EquipmentId).HasMaxLength(64);
|
||||
e.Property(x => x.EquipmentUuid).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.UnsAreaName).HasMaxLength(64);
|
||||
e.Property(x => x.UnsLineName).HasMaxLength(64);
|
||||
e.Property(x => x.Manufacturer).HasMaxLength(256);
|
||||
e.Property(x => x.Model).HasMaxLength(256);
|
||||
e.Property(x => x.SerialNumber).HasMaxLength(256);
|
||||
e.Property(x => x.HardwareRevision).HasMaxLength(64);
|
||||
e.Property(x => x.SoftwareRevision).HasMaxLength(64);
|
||||
e.Property(x => x.YearOfConstruction).HasMaxLength(8);
|
||||
e.Property(x => x.AssetLocation).HasMaxLength(512);
|
||||
e.Property(x => x.ManufacturerUri).HasMaxLength(512);
|
||||
e.Property(x => x.DeviceManualUri).HasMaxLength(512);
|
||||
e.Property(x => x.RejectReason).HasMaxLength(512);
|
||||
|
||||
e.HasOne(x => x.Batch)
|
||||
.WithMany(b => b.Rows)
|
||||
.HasForeignKey(x => x.BatchId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
e.HasIndex(x => x.BatchId).HasDatabaseName("IX_EquipmentImportRow_Batch");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Optional driver capability that maps a per-tag full reference to the underlying host
|
||||
/// name responsible for serving it. Drivers with a one-host topology (Galaxy on one
|
||||
/// MXAccess endpoint, OpcUaClient against one remote server, S7 against one PLC) do NOT
|
||||
/// need to implement this — the dispatch layer falls back to
|
||||
/// <see cref="IDriver.DriverInstanceId"/> as a single-host key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Multi-host drivers (Modbus with N PLCs, hypothetical AB CIP across a rack, etc.)
|
||||
/// implement this so the Phase 6.1 resilience pipeline can be keyed on
|
||||
/// <c>(DriverInstanceId, ResolvedHostName, DriverCapability)</c> per decision #144. One
|
||||
/// dead PLC behind a multi-device Modbus driver then trips only its own breaker; healthy
|
||||
/// siblings keep serving.</para>
|
||||
///
|
||||
/// <para>Implementations must be fast + allocation-free on the hot path — <c>ReadAsync</c>
|
||||
/// / <c>WriteAsync</c> call this once per tag. A simple <c>Dictionary<string, string></c>
|
||||
/// lookup is typical.</para>
|
||||
///
|
||||
/// <para>When the fullRef doesn't map to a known host (caller passes an unregistered
|
||||
/// reference, or the tag was removed mid-flight), implementations should return the
|
||||
/// driver's default-host string rather than throwing — the invoker falls back to a
|
||||
/// single-host pipeline for that call, which is safer than tearing down the request.</para>
|
||||
/// </remarks>
|
||||
public interface IPerCallHostResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve the host name for the given driver-side full reference. Returned value is
|
||||
/// used as the <c>hostName</c> argument to the Phase 6.1 <c>CapabilityInvoker</c> so
|
||||
/// per-host breaker isolation + per-host bulkhead accounting both kick in.
|
||||
/// </summary>
|
||||
string ResolveHost(string fullReference);
|
||||
}
|
||||
146
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs
Normal file
146
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs
Normal file
@@ -0,0 +1,146 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Shared poll-based subscription engine for drivers whose underlying protocol has no
|
||||
/// native push model (Modbus, AB CIP, S7, FOCAS). Owns one background Task per subscription
|
||||
/// that periodically invokes the supplied reader, diffs each snapshot against the last
|
||||
/// known value, and dispatches a change callback per changed tag. Extracted from
|
||||
/// <c>ModbusDriver</c> (AB CIP PR 1) so poll-based drivers don't each re-ship the loop,
|
||||
/// floor logic, and lifecycle plumbing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The engine is read-path agnostic: it calls the supplied <c>reader</c> delegate
|
||||
/// and trusts the driver to map protocol errors into <see cref="DataValueSnapshot.StatusCode"/>.
|
||||
/// Callbacks fire on: (a) the first poll after subscribe (initial-data push per the OPC UA
|
||||
/// Part 4 convention), (b) any subsequent poll where the boxed value or status code differs
|
||||
/// from the previously-seen snapshot.</para>
|
||||
///
|
||||
/// <para>Exceptions thrown by the reader on the initial poll or any subsequent poll are
|
||||
/// swallowed — the loop continues on the next tick. The driver's own health surface is
|
||||
/// where transient poll failures should be reported; the engine intentionally does not
|
||||
/// double-book that responsibility.</para>
|
||||
/// </remarks>
|
||||
public sealed class PollGroupEngine : IAsyncDisposable
|
||||
{
|
||||
private readonly Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> _reader;
|
||||
private readonly Action<ISubscriptionHandle, string, DataValueSnapshot> _onChange;
|
||||
private readonly TimeSpan _minInterval;
|
||||
private readonly ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
|
||||
private long _nextId;
|
||||
|
||||
/// <summary>Default floor for publishing intervals — matches the Modbus 100 ms cap.</summary>
|
||||
public static readonly TimeSpan DefaultMinInterval = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <param name="reader">Driver-supplied batch reader; snapshots MUST be returned in the same
|
||||
/// order as the input references.</param>
|
||||
/// <param name="onChange">Callback invoked per changed tag — the driver forwards to its own
|
||||
/// <see cref="ISubscribable.OnDataChange"/> event.</param>
|
||||
/// <param name="minInterval">Interval floor; anything below is clamped. Defaults to 100 ms
|
||||
/// per <see cref="DefaultMinInterval"/>.</param>
|
||||
public PollGroupEngine(
|
||||
Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> reader,
|
||||
Action<ISubscriptionHandle, string, DataValueSnapshot> onChange,
|
||||
TimeSpan? minInterval = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(reader);
|
||||
ArgumentNullException.ThrowIfNull(onChange);
|
||||
_reader = reader;
|
||||
_onChange = onChange;
|
||||
_minInterval = minInterval ?? DefaultMinInterval;
|
||||
}
|
||||
|
||||
/// <summary>Register a new polled subscription and start its background loop.</summary>
|
||||
public ISubscriptionHandle Subscribe(IReadOnlyList<string> fullReferences, TimeSpan publishingInterval)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
var id = Interlocked.Increment(ref _nextId);
|
||||
var cts = new CancellationTokenSource();
|
||||
var interval = publishingInterval < _minInterval ? _minInterval : publishingInterval;
|
||||
var handle = new PollSubscriptionHandle(id);
|
||||
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
|
||||
_subscriptions[id] = state;
|
||||
_ = Task.Run(() => PollLoopAsync(state, cts.Token), cts.Token);
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>Cancel the background loop for a handle returned by <see cref="Subscribe"/>.</summary>
|
||||
/// <returns><c>true</c> when the handle was known to the engine and has been torn down.</returns>
|
||||
public bool Unsubscribe(ISubscriptionHandle handle)
|
||||
{
|
||||
if (handle is PollSubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
|
||||
{
|
||||
try { state.Cts.Cancel(); } catch { }
|
||||
state.Cts.Dispose();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of active subscription count — exposed for driver diagnostics.</summary>
|
||||
public int ActiveSubscriptionCount => _subscriptions.Count;
|
||||
|
||||
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
|
||||
{
|
||||
// Initial-data push: every subscribed tag fires once at subscribe time regardless of
|
||||
// whether it has changed, satisfying OPC UA Part 4 initial-value semantics.
|
||||
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch { /* first-read error tolerated — loop continues */ }
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch { /* transient poll error — loop continues, driver health surface logs it */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
|
||||
{
|
||||
var snapshots = await _reader(state.TagReferences, ct).ConfigureAwait(false);
|
||||
for (var i = 0; i < state.TagReferences.Count; i++)
|
||||
{
|
||||
var tagRef = state.TagReferences[i];
|
||||
var current = snapshots[i];
|
||||
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
|
||||
|
||||
if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
|
||||
{
|
||||
state.LastValues[tagRef] = current;
|
||||
_onChange(state.Handle, tagRef, current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cancel every active subscription. Idempotent.</summary>
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var state in _subscriptions.Values)
|
||||
{
|
||||
try { state.Cts.Cancel(); } catch { }
|
||||
state.Cts.Dispose();
|
||||
}
|
||||
_subscriptions.Clear();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed record SubscriptionState(
|
||||
PollSubscriptionHandle Handle,
|
||||
IReadOnlyList<string> TagReferences,
|
||||
TimeSpan Interval,
|
||||
CancellationTokenSource Cts)
|
||||
{
|
||||
public ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed record PollSubscriptionHandle(long Id) : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"poll-sub-{Id}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.4 Stream D: materializes the OPC 40010 Machinery companion-spec Identification
|
||||
/// sub-folder under an Equipment node. Reads the nine decision-#139 columns off the
|
||||
/// <see cref="Equipment"/> row and emits one property per non-null field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Pure-function shape — testable without a real OPC UA node manager. The caller
|
||||
/// passes the builder scoped to the Equipment node; this class handles the Identification
|
||||
/// sub-folder creation + per-field <see cref="IAddressSpaceBuilder.AddProperty"/> calls.</para>
|
||||
///
|
||||
/// <para>ACL binding: the sub-folder + its properties inherit the Equipment scope's
|
||||
/// grants (no new scope level). Phase 6.2's trie treats them as part of the Equipment
|
||||
/// ScopeId — a user with Equipment-level grant reads Identification; a user without the
|
||||
/// grant gets BadUserAccessDenied on both the Equipment node + its Identification variables.
|
||||
/// See <c>docs/v2/acl-design.md</c> §Identification cross-reference.</para>
|
||||
///
|
||||
/// <para>The nine fields per decision #139 are exposed exactly when they carry a non-null
|
||||
/// value. A row with all nine null produces no Identification sub-folder at all — the
|
||||
/// caller can use <see cref="HasAnyFields(Equipment)"/> to skip the Folder call entirely
|
||||
/// and avoid a pointless empty folder appearing in browse trees.</para>
|
||||
/// </remarks>
|
||||
public static class IdentificationFolderBuilder
|
||||
{
|
||||
/// <summary>Browse + display name of the sub-folder — fixed per OPC 40010 convention.</summary>
|
||||
public const string FolderName = "Identification";
|
||||
|
||||
/// <summary>
|
||||
/// Canonical decision #139 field set exposed in the Identification sub-folder. Order
|
||||
/// matches the decision-log entry so any browse-order reader can cross-reference
|
||||
/// without re-sorting.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> FieldNames { get; } = new[]
|
||||
{
|
||||
"Manufacturer", "Model", "SerialNumber",
|
||||
"HardwareRevision", "SoftwareRevision",
|
||||
"YearOfConstruction", "AssetLocation",
|
||||
"ManufacturerUri", "DeviceManualUri",
|
||||
};
|
||||
|
||||
/// <summary>True when the equipment row has at least one non-null Identification field.</summary>
|
||||
public static bool HasAnyFields(Equipment equipment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(equipment);
|
||||
return equipment.Manufacturer is not null
|
||||
|| equipment.Model is not null
|
||||
|| equipment.SerialNumber is not null
|
||||
|| equipment.HardwareRevision is not null
|
||||
|| equipment.SoftwareRevision is not null
|
||||
|| equipment.YearOfConstruction is not null
|
||||
|| equipment.AssetLocation is not null
|
||||
|| equipment.ManufacturerUri is not null
|
||||
|| equipment.DeviceManualUri is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the Identification sub-folder under <paramref name="equipmentBuilder"/>. No-op
|
||||
/// when every field is null. Returns the sub-folder builder (or null when no-op) so
|
||||
/// callers can attach additional nodes underneath if needed.
|
||||
/// </summary>
|
||||
public static IAddressSpaceBuilder? Build(IAddressSpaceBuilder equipmentBuilder, Equipment equipment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(equipmentBuilder);
|
||||
ArgumentNullException.ThrowIfNull(equipment);
|
||||
|
||||
if (!HasAnyFields(equipment)) return null;
|
||||
|
||||
var folder = equipmentBuilder.Folder(FolderName, FolderName);
|
||||
AddIfPresent(folder, "Manufacturer", DriverDataType.String, equipment.Manufacturer);
|
||||
AddIfPresent(folder, "Model", DriverDataType.String, equipment.Model);
|
||||
AddIfPresent(folder, "SerialNumber", DriverDataType.String, equipment.SerialNumber);
|
||||
AddIfPresent(folder, "HardwareRevision", DriverDataType.String, equipment.HardwareRevision);
|
||||
AddIfPresent(folder, "SoftwareRevision", DriverDataType.String, equipment.SoftwareRevision);
|
||||
AddIfPresent(folder, "YearOfConstruction", DriverDataType.Int32,
|
||||
equipment.YearOfConstruction is null ? null : (object)(int)equipment.YearOfConstruction.Value);
|
||||
AddIfPresent(folder, "AssetLocation", DriverDataType.String, equipment.AssetLocation);
|
||||
AddIfPresent(folder, "ManufacturerUri", DriverDataType.String, equipment.ManufacturerUri);
|
||||
AddIfPresent(folder, "DeviceManualUri", DriverDataType.String, equipment.DeviceManualUri);
|
||||
return folder;
|
||||
}
|
||||
|
||||
private static void AddIfPresent(IAddressSpaceBuilder folder, string name, DriverDataType dataType, object? value)
|
||||
{
|
||||
if (value is null) return;
|
||||
folder.AddProperty(name, dataType, value);
|
||||
}
|
||||
}
|
||||
129
src/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs
Normal file
129
src/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the three mutating surfaces of <see cref="IAlarmSource"/>
|
||||
/// (<see cref="IAlarmSource.SubscribeAlarmsAsync"/>, <see cref="IAlarmSource.UnsubscribeAlarmsAsync"/>,
|
||||
/// <see cref="IAlarmSource.AcknowledgeAsync"/>) through <see cref="CapabilityInvoker"/> so the
|
||||
/// Phase 6.1 resilience pipeline runs — retry semantics match
|
||||
/// <see cref="DriverCapability.AlarmSubscribe"/> (retries by default) and
|
||||
/// <see cref="DriverCapability.AlarmAcknowledge"/> (does NOT retry per decision #143).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Multi-host dispatch: when the driver implements <see cref="IPerCallHostResolver"/>,
|
||||
/// each source-node-id is resolved individually + grouped by host so a dead PLC inside a
|
||||
/// multi-device driver doesn't poison the sibling hosts' breakers. Drivers with a single
|
||||
/// host fall back to <see cref="IDriver.DriverInstanceId"/> as the single-host key.</para>
|
||||
///
|
||||
/// <para>Why this lives here + not on <see cref="CapabilityInvoker"/>: alarm surfaces have a
|
||||
/// handle-returning shape (SubscribeAlarmsAsync returns <see cref="IAlarmSubscriptionHandle"/>)
|
||||
/// + a per-call fan-out (AcknowledgeAsync gets a batch of
|
||||
/// <see cref="AlarmAcknowledgeRequest"/>s that may span multiple hosts). Keeping the fan-out
|
||||
/// logic here keeps the invoker's execute-overloads narrow.</para>
|
||||
/// </remarks>
|
||||
public sealed class AlarmSurfaceInvoker
|
||||
{
|
||||
private readonly CapabilityInvoker _invoker;
|
||||
private readonly IAlarmSource _alarmSource;
|
||||
private readonly IPerCallHostResolver? _hostResolver;
|
||||
private readonly string _defaultHost;
|
||||
|
||||
public AlarmSurfaceInvoker(
|
||||
CapabilityInvoker invoker,
|
||||
IAlarmSource alarmSource,
|
||||
string defaultHost,
|
||||
IPerCallHostResolver? hostResolver = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(invoker);
|
||||
ArgumentNullException.ThrowIfNull(alarmSource);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(defaultHost);
|
||||
|
||||
_invoker = invoker;
|
||||
_alarmSource = alarmSource;
|
||||
_defaultHost = defaultHost;
|
||||
_hostResolver = hostResolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to alarm events for a set of source node ids, fanning out by resolved host
|
||||
/// so per-host breakers / bulkheads apply. Returns one handle per host — callers that
|
||||
/// don't care about per-host separation may concatenate them.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<IAlarmSubscriptionHandle>> SubscribeAsync(
|
||||
IReadOnlyList<string> sourceNodeIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sourceNodeIds);
|
||||
if (sourceNodeIds.Count == 0) return [];
|
||||
|
||||
var byHost = GroupByHost(sourceNodeIds);
|
||||
var handles = new List<IAlarmSubscriptionHandle>(byHost.Count);
|
||||
foreach (var (host, ids) in byHost)
|
||||
{
|
||||
var handle = await _invoker.ExecuteAsync(
|
||||
DriverCapability.AlarmSubscribe,
|
||||
host,
|
||||
async ct => await _alarmSource.SubscribeAlarmsAsync(ids, ct).ConfigureAwait(false),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
handles.Add(handle);
|
||||
}
|
||||
return handles;
|
||||
}
|
||||
|
||||
/// <summary>Cancel an alarm subscription. Routes through the AlarmSubscribe pipeline for parity.</summary>
|
||||
public ValueTask UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handle);
|
||||
return _invoker.ExecuteAsync(
|
||||
DriverCapability.AlarmSubscribe,
|
||||
_defaultHost,
|
||||
async ct => await _alarmSource.UnsubscribeAlarmsAsync(handle, ct).ConfigureAwait(false),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge alarms. Fans out by resolved host; each host's batch runs through the
|
||||
/// AlarmAcknowledge pipeline (no-retry per decision #143 — an alarm-ack is not idempotent
|
||||
/// at the plant-floor acknowledgement level even if the OPC UA spec permits re-issue).
|
||||
/// </summary>
|
||||
public async Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(acknowledgements);
|
||||
if (acknowledgements.Count == 0) return;
|
||||
|
||||
var byHost = _hostResolver is null
|
||||
? new Dictionary<string, List<AlarmAcknowledgeRequest>> { [_defaultHost] = acknowledgements.ToList() }
|
||||
: acknowledgements
|
||||
.GroupBy(a => _hostResolver.ResolveHost(a.SourceNodeId))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
foreach (var (host, batch) in byHost)
|
||||
{
|
||||
var batchSnapshot = batch; // capture for the lambda
|
||||
await _invoker.ExecuteAsync(
|
||||
DriverCapability.AlarmAcknowledge,
|
||||
host,
|
||||
async ct => await _alarmSource.AcknowledgeAsync(batchSnapshot, ct).ConfigureAwait(false),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, List<string>> GroupByHost(IReadOnlyList<string> sourceNodeIds)
|
||||
{
|
||||
if (_hostResolver is null)
|
||||
return new Dictionary<string, List<string>> { [_defaultHost] = sourceNodeIds.ToList() };
|
||||
|
||||
var result = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var id in sourceNodeIds)
|
||||
{
|
||||
var host = _hostResolver.ResolveHost(id);
|
||||
if (!result.TryGetValue(host, out var list))
|
||||
result[host] = list = new List<string>();
|
||||
list.Add(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ public sealed class CapabilityInvoker
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly string _driverType;
|
||||
private readonly Func<DriverResilienceOptions> _optionsAccessor;
|
||||
private readonly DriverResilienceStatusTracker? _statusTracker;
|
||||
|
||||
/// <summary>
|
||||
/// Construct an invoker for one driver instance.
|
||||
@@ -33,11 +34,13 @@ public sealed class CapabilityInvoker
|
||||
/// pipeline-invalidate can take effect without restarting the invoker.
|
||||
/// </param>
|
||||
/// <param name="driverType">Driver type name for structured-log enrichment (e.g. <c>"Modbus"</c>).</param>
|
||||
/// <param name="statusTracker">Optional resilience-status tracker. When wired, every capability call records start/complete so Admin <c>/hosts</c> can surface <see cref="ResilienceStatusSnapshot.CurrentInFlight"/> as the bulkhead-depth proxy.</param>
|
||||
public CapabilityInvoker(
|
||||
DriverResiliencePipelineBuilder builder,
|
||||
string driverInstanceId,
|
||||
Func<DriverResilienceOptions> optionsAccessor,
|
||||
string driverType = "Unknown")
|
||||
string driverType = "Unknown",
|
||||
DriverResilienceStatusTracker? statusTracker = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(optionsAccessor);
|
||||
@@ -46,6 +49,7 @@ public sealed class CapabilityInvoker
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_driverType = driverType;
|
||||
_optionsAccessor = optionsAccessor;
|
||||
_statusTracker = statusTracker;
|
||||
}
|
||||
|
||||
/// <summary>Execute a capability call returning a value, honoring the per-capability pipeline.</summary>
|
||||
@@ -59,9 +63,17 @@ public sealed class CapabilityInvoker
|
||||
ArgumentNullException.ThrowIfNull(callSite);
|
||||
|
||||
var pipeline = ResolvePipeline(capability, hostName);
|
||||
using (LogContextEnricher.Push(_driverInstanceId, _driverType, capability, LogContextEnricher.NewCorrelationId()))
|
||||
_statusTracker?.RecordCallStart(_driverInstanceId, hostName);
|
||||
try
|
||||
{
|
||||
return await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false);
|
||||
using (LogContextEnricher.Push(_driverInstanceId, _driverType, capability, LogContextEnricher.NewCorrelationId()))
|
||||
{
|
||||
return await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_statusTracker?.RecordCallComplete(_driverInstanceId, hostName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,9 +87,17 @@ public sealed class CapabilityInvoker
|
||||
ArgumentNullException.ThrowIfNull(callSite);
|
||||
|
||||
var pipeline = ResolvePipeline(capability, hostName);
|
||||
using (LogContextEnricher.Push(_driverInstanceId, _driverType, capability, LogContextEnricher.NewCorrelationId()))
|
||||
_statusTracker?.RecordCallStart(_driverInstanceId, hostName);
|
||||
try
|
||||
{
|
||||
await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false);
|
||||
using (LogContextEnricher.Push(_driverInstanceId, _driverType, capability, LogContextEnricher.NewCorrelationId()))
|
||||
{
|
||||
await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_statusTracker?.RecordCallComplete(_driverInstanceId, hostName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the <c>DriverInstance.ResilienceConfig</c> JSON column into a
|
||||
/// <see cref="DriverResilienceOptions"/> instance layered on top of the tier defaults.
|
||||
/// Every key in the JSON is optional; missing keys fall back to the tier defaults from
|
||||
/// <see cref="DriverResilienceOptions.GetTierDefaults(DriverTier)"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Example JSON shape per Phase 6.1 Stream A.2:</para>
|
||||
/// <code>
|
||||
/// {
|
||||
/// "bulkheadMaxConcurrent": 16,
|
||||
/// "bulkheadMaxQueue": 64,
|
||||
/// "capabilityPolicies": {
|
||||
/// "Read": { "timeoutSeconds": 5, "retryCount": 5, "breakerFailureThreshold": 3 },
|
||||
/// "Write": { "timeoutSeconds": 5, "retryCount": 0, "breakerFailureThreshold": 5 }
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
///
|
||||
/// <para>Unrecognised keys + values are ignored so future shapes land without a migration.
|
||||
/// Per-capability overrides are layered on top of tier defaults — a partial policy (only
|
||||
/// some of TimeoutSeconds/RetryCount/BreakerFailureThreshold) fills in the other fields
|
||||
/// from the tier default for that capability.</para>
|
||||
///
|
||||
/// <para>Parser failures (malformed JSON, type mismatches) fall back to pure tier defaults
|
||||
/// + surface through an out-parameter diagnostic. Callers may log the diagnostic but should
|
||||
/// NOT fail driver startup — a misconfigured ResilienceConfig should never brick a
|
||||
/// working driver.</para>
|
||||
/// </remarks>
|
||||
public static class DriverResilienceOptionsParser
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parse the JSON payload layered on <paramref name="tier"/>'s defaults. Returns the
|
||||
/// effective options; <paramref name="parseDiagnostic"/> is null on success, or a
|
||||
/// human-readable error message when the JSON was malformed (options still returned
|
||||
/// = tier defaults).
|
||||
/// </summary>
|
||||
public static DriverResilienceOptions ParseOrDefaults(
|
||||
DriverTier tier,
|
||||
string? resilienceConfigJson,
|
||||
out string? parseDiagnostic)
|
||||
{
|
||||
parseDiagnostic = null;
|
||||
var baseDefaults = DriverResilienceOptions.GetTierDefaults(tier);
|
||||
var baseOptions = new DriverResilienceOptions { Tier = tier, CapabilityPolicies = baseDefaults };
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resilienceConfigJson))
|
||||
return baseOptions;
|
||||
|
||||
ResilienceConfigShape? shape;
|
||||
try
|
||||
{
|
||||
shape = JsonSerializer.Deserialize<ResilienceConfigShape>(resilienceConfigJson, JsonOpts);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
parseDiagnostic = $"ResilienceConfig JSON malformed; falling back to tier {tier} defaults. Detail: {ex.Message}";
|
||||
return baseOptions;
|
||||
}
|
||||
|
||||
if (shape is null) return baseOptions;
|
||||
|
||||
var merged = new Dictionary<DriverCapability, CapabilityPolicy>(baseDefaults);
|
||||
if (shape.CapabilityPolicies is not null)
|
||||
{
|
||||
foreach (var (capName, overridePolicy) in shape.CapabilityPolicies)
|
||||
{
|
||||
if (!Enum.TryParse<DriverCapability>(capName, ignoreCase: true, out var capability))
|
||||
{
|
||||
parseDiagnostic ??= $"Unknown capability '{capName}' in ResilienceConfig; skipped.";
|
||||
continue;
|
||||
}
|
||||
|
||||
var basePolicy = merged[capability];
|
||||
merged[capability] = new CapabilityPolicy(
|
||||
TimeoutSeconds: overridePolicy.TimeoutSeconds ?? basePolicy.TimeoutSeconds,
|
||||
RetryCount: overridePolicy.RetryCount ?? basePolicy.RetryCount,
|
||||
BreakerFailureThreshold: overridePolicy.BreakerFailureThreshold ?? basePolicy.BreakerFailureThreshold);
|
||||
}
|
||||
}
|
||||
|
||||
return new DriverResilienceOptions
|
||||
{
|
||||
Tier = tier,
|
||||
CapabilityPolicies = merged,
|
||||
BulkheadMaxConcurrent = shape.BulkheadMaxConcurrent ?? baseOptions.BulkheadMaxConcurrent,
|
||||
BulkheadMaxQueue = shape.BulkheadMaxQueue ?? baseOptions.BulkheadMaxQueue,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class ResilienceConfigShape
|
||||
{
|
||||
public int? BulkheadMaxConcurrent { get; set; }
|
||||
public int? BulkheadMaxQueue { get; set; }
|
||||
public Dictionary<string, CapabilityPolicyShape>? CapabilityPolicies { get; set; }
|
||||
}
|
||||
|
||||
private sealed class CapabilityPolicyShape
|
||||
{
|
||||
public int? TimeoutSeconds { get; set; }
|
||||
public int? RetryCount { get; set; }
|
||||
public int? BreakerFailureThreshold { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,21 @@ public sealed class DriverResiliencePipelineBuilder
|
||||
{
|
||||
private readonly ConcurrentDictionary<PipelineKey, ResiliencePipeline> _pipelines = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly DriverResilienceStatusTracker? _statusTracker;
|
||||
|
||||
/// <summary>Construct with the ambient clock (use <see cref="TimeProvider.System"/> in prod).</summary>
|
||||
public DriverResiliencePipelineBuilder(TimeProvider? timeProvider = null)
|
||||
/// <param name="timeProvider">Clock source for pipeline timeouts + breaker sampling. Defaults to system.</param>
|
||||
/// <param name="statusTracker">When non-null, every built pipeline wires Polly telemetry into
|
||||
/// the tracker — retries increment <c>ConsecutiveFailures</c>, breaker-open stamps
|
||||
/// <c>LastBreakerOpenUtc</c>, breaker-close resets failures. Feeds Admin <c>/hosts</c> +
|
||||
/// the Polly bulkhead-depth column. Absent tracker means no telemetry (unit tests +
|
||||
/// deployments that don't care about resilience observability).</param>
|
||||
public DriverResiliencePipelineBuilder(
|
||||
TimeProvider? timeProvider = null,
|
||||
DriverResilienceStatusTracker? statusTracker = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_statusTracker = statusTracker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -54,8 +64,9 @@ public sealed class DriverResiliencePipelineBuilder
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(hostName);
|
||||
|
||||
var key = new PipelineKey(driverInstanceId, hostName, capability);
|
||||
return _pipelines.GetOrAdd(key, static (_, state) => Build(state.capability, state.options, state.timeProvider),
|
||||
(capability, options, timeProvider: _timeProvider));
|
||||
return _pipelines.GetOrAdd(key, static (k, state) => Build(
|
||||
k.DriverInstanceId, k.HostName, state.capability, state.options, state.timeProvider, state.tracker),
|
||||
(capability, options, timeProvider: _timeProvider, tracker: _statusTracker));
|
||||
}
|
||||
|
||||
/// <summary>Drop cached pipelines for one driver instance (e.g. on ResilienceConfig change). Test + Admin-reload use.</summary>
|
||||
@@ -74,9 +85,12 @@ public sealed class DriverResiliencePipelineBuilder
|
||||
public int CachedPipelineCount => _pipelines.Count;
|
||||
|
||||
private static ResiliencePipeline Build(
|
||||
string driverInstanceId,
|
||||
string hostName,
|
||||
DriverCapability capability,
|
||||
DriverResilienceOptions options,
|
||||
TimeProvider timeProvider)
|
||||
TimeProvider timeProvider,
|
||||
DriverResilienceStatusTracker? tracker)
|
||||
{
|
||||
var policy = options.Resolve(capability);
|
||||
var builder = new ResiliencePipelineBuilder { TimeProvider = timeProvider };
|
||||
@@ -88,7 +102,7 @@ public sealed class DriverResiliencePipelineBuilder
|
||||
|
||||
if (policy.RetryCount > 0)
|
||||
{
|
||||
builder.AddRetry(new RetryStrategyOptions
|
||||
var retryOptions = new RetryStrategyOptions
|
||||
{
|
||||
MaxRetryAttempts = policy.RetryCount,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
@@ -96,19 +110,44 @@ public sealed class DriverResiliencePipelineBuilder
|
||||
Delay = TimeSpan.FromMilliseconds(100),
|
||||
MaxDelay = TimeSpan.FromSeconds(5),
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex => ex is not OperationCanceledException),
|
||||
});
|
||||
};
|
||||
if (tracker is not null)
|
||||
{
|
||||
retryOptions.OnRetry = args =>
|
||||
{
|
||||
tracker.RecordFailure(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime);
|
||||
return default;
|
||||
};
|
||||
}
|
||||
builder.AddRetry(retryOptions);
|
||||
}
|
||||
|
||||
if (policy.BreakerFailureThreshold > 0)
|
||||
{
|
||||
builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions
|
||||
var breakerOptions = new CircuitBreakerStrategyOptions
|
||||
{
|
||||
FailureRatio = 1.0,
|
||||
MinimumThroughput = policy.BreakerFailureThreshold,
|
||||
SamplingDuration = TimeSpan.FromSeconds(30),
|
||||
BreakDuration = TimeSpan.FromSeconds(15),
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex => ex is not OperationCanceledException),
|
||||
});
|
||||
};
|
||||
if (tracker is not null)
|
||||
{
|
||||
breakerOptions.OnOpened = args =>
|
||||
{
|
||||
tracker.RecordBreakerOpen(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime);
|
||||
return default;
|
||||
};
|
||||
breakerOptions.OnClosed = args =>
|
||||
{
|
||||
// Closing the breaker means the target recovered — reset the consecutive-
|
||||
// failure counter so Admin UI stops flashing red for this host.
|
||||
tracker.RecordSuccess(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime);
|
||||
return default;
|
||||
};
|
||||
}
|
||||
builder.AddCircuitBreaker(breakerOptions);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
|
||||
@@ -81,6 +81,29 @@ public sealed class DriverResilienceStatusTracker
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record the entry of a capability call for this (instance, host). Increments the
|
||||
/// in-flight counter used as the <see cref="ResilienceStatusSnapshot.CurrentInFlight"/>
|
||||
/// surface (a cheap stand-in for Polly bulkhead depth). Paired with
|
||||
/// <see cref="RecordCallComplete"/>; callers use try/finally.
|
||||
/// </summary>
|
||||
public void RecordCallStart(string driverInstanceId, string hostName)
|
||||
{
|
||||
var key = new StatusKey(driverInstanceId, hostName);
|
||||
_status.AddOrUpdate(key,
|
||||
_ => new ResilienceStatusSnapshot { CurrentInFlight = 1 },
|
||||
(_, existing) => existing with { CurrentInFlight = existing.CurrentInFlight + 1 });
|
||||
}
|
||||
|
||||
/// <summary>Paired with <see cref="RecordCallStart"/> — decrements the in-flight counter.</summary>
|
||||
public void RecordCallComplete(string driverInstanceId, string hostName)
|
||||
{
|
||||
var key = new StatusKey(driverInstanceId, hostName);
|
||||
_status.AddOrUpdate(key,
|
||||
_ => new ResilienceStatusSnapshot { CurrentInFlight = 0 }, // start-without-complete shouldn't happen; clamp to 0
|
||||
(_, existing) => existing with { CurrentInFlight = Math.Max(0, existing.CurrentInFlight - 1) });
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of a specific (instance, host) pair; null if no counters recorded yet.</summary>
|
||||
public ResilienceStatusSnapshot? TryGet(string driverInstanceId, string hostName) =>
|
||||
_status.TryGetValue(new StatusKey(driverInstanceId, hostName), out var snapshot) ? snapshot : null;
|
||||
@@ -101,4 +124,12 @@ public sealed record ResilienceStatusSnapshot
|
||||
public long BaselineFootprintBytes { get; init; }
|
||||
public long CurrentFootprintBytes { get; init; }
|
||||
public DateTime LastSampledUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// In-flight capability calls against this (instance, host). Bumped on call entry +
|
||||
/// decremented on completion. Feeds <c>DriverInstanceResilienceStatus.CurrentBulkheadDepth</c>
|
||||
/// for Admin <c>/hosts</c> — a cheap proxy for the Polly bulkhead depth until the full
|
||||
/// telemetry observer lands.
|
||||
/// </summary>
|
||||
public int CurrentInFlight { get; init; }
|
||||
}
|
||||
|
||||
61
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDataType.cs
Normal file
61
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDataType.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Logix atomic + string data types, plus a <see cref="Structure"/> marker used when a tag
|
||||
/// references a UDT / predefined structure (Timer, Counter, Control). The concrete UDT
|
||||
/// shape is resolved via the CIP Template Object at discovery time (PR 5 / PR 6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors the shape of <c>ModbusDataType</c>. Atomic Logix names (BOOL / SINT / INT / DINT /
|
||||
/// LINT / REAL / LREAL / STRING / DT) map one-to-one; BIT + BOOL-in-DINT collapse into
|
||||
/// <see cref="Bool"/> with the <c>.N</c> bit-index carried on the <see cref="AbCipTagPath"/>
|
||||
/// rather than the data type itself.
|
||||
/// </remarks>
|
||||
public enum AbCipDataType
|
||||
{
|
||||
Bool,
|
||||
SInt, // signed 8-bit
|
||||
Int, // signed 16-bit
|
||||
DInt, // signed 32-bit
|
||||
LInt, // signed 64-bit
|
||||
USInt, // unsigned 8-bit (Logix 5000 post-V21)
|
||||
UInt, // unsigned 16-bit
|
||||
UDInt, // unsigned 32-bit
|
||||
ULInt, // unsigned 64-bit
|
||||
Real, // 32-bit IEEE-754
|
||||
LReal, // 64-bit IEEE-754
|
||||
String, // Logix STRING (DINT Length + SINT[82] DATA — flattened to .NET string by libplctag)
|
||||
Dt, // Date/Time — Logix DT == DINT representing seconds-since-epoch per Rockwell conventions
|
||||
/// <summary>
|
||||
/// UDT / Predefined Structure (Timer / Counter / Control / Message / Axis). Shape is
|
||||
/// resolved at discovery time; reads + writes fan out to member Variables unless the
|
||||
/// caller has explicitly opted into whole-UDT decode.
|
||||
/// </summary>
|
||||
Structure,
|
||||
}
|
||||
|
||||
/// <summary>Map a Logix atomic type to the driver-surface <see cref="DriverDataType"/>.</summary>
|
||||
public static class AbCipDataTypeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Map to the driver-agnostic type the server's address-space builder consumes. Unsigned
|
||||
/// Logix types widen into signed equivalents until <c>DriverDataType</c> picks up unsigned
|
||||
/// + 64-bit variants (Modbus has the same gap — see <c>ModbusDriver.MapDataType</c>
|
||||
/// comment re: PR 25).
|
||||
/// </summary>
|
||||
public static DriverDataType ToDriverDataType(this AbCipDataType t) => t switch
|
||||
{
|
||||
AbCipDataType.Bool => DriverDataType.Boolean,
|
||||
AbCipDataType.SInt or AbCipDataType.Int or AbCipDataType.DInt => DriverDataType.Int32,
|
||||
AbCipDataType.USInt or AbCipDataType.UInt or AbCipDataType.UDInt => DriverDataType.Int32,
|
||||
AbCipDataType.LInt or AbCipDataType.ULInt => DriverDataType.Int32, // TODO: Int64 — matches Modbus gap
|
||||
AbCipDataType.Real => DriverDataType.Float32,
|
||||
AbCipDataType.LReal => DriverDataType.Float64,
|
||||
AbCipDataType.String => DriverDataType.String,
|
||||
AbCipDataType.Dt => DriverDataType.Int32, // epoch-seconds DINT
|
||||
AbCipDataType.Structure => DriverDataType.String, // placeholder until UDT PR 6 introduces a structured kind
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
}
|
||||
729
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
Normal file
729
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
Normal file
@@ -0,0 +1,729 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Allen-Bradley CIP / EtherNet-IP driver for ControlLogix / CompactLogix / Micro800 /
|
||||
/// GuardLogix families. Implements <see cref="IDriver"/> only for now — read/write/
|
||||
/// subscribe/discover capabilities ship in subsequent PRs (3–8) and family-specific quirk
|
||||
/// profiles ship in PRs 9–12.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Wire layer is libplctag 1.6.x (plan decision #11). Per-device host addresses use
|
||||
/// the <c>ab://gateway[:port]/cip-path</c> canonical form parsed via
|
||||
/// <see cref="AbCipHostAddress.TryParse"/>; those strings become the <c>hostName</c> key
|
||||
/// for Polly bulkhead + circuit-breaker isolation per plan decision #144.</para>
|
||||
///
|
||||
/// <para>Tier A per plan decisions #143–145 — in-process, shares server lifetime, no
|
||||
/// sidecar. <see cref="ReinitializeAsync"/> is the Tier-B escape hatch for recovering
|
||||
/// from native-heap growth that the CLR allocator can't see; it tears down every
|
||||
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
|
||||
/// </remarks>
|
||||
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly AbCipDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IAbCipTagFactory _tagFactory;
|
||||
private readonly IAbCipTagEnumeratorFactory _enumeratorFactory;
|
||||
private readonly IAbCipTemplateReaderFactory _templateReaderFactory;
|
||||
private readonly AbCipTemplateCache _templateCache = new();
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
|
||||
IAbCipTagFactory? tagFactory = null,
|
||||
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
|
||||
IAbCipTemplateReaderFactory? templateReaderFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_tagFactory = tagFactory ?? new LibplctagTagFactory();
|
||||
_enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
|
||||
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch + cache the shape of a Logix UDT by template instance id. First call reads
|
||||
/// the Template Object off the controller; subsequent calls for the same
|
||||
/// <c>(deviceHostAddress, templateInstanceId)</c> return the cached shape without
|
||||
/// additional network traffic. <c>null</c> on template-not-found / decode failure so
|
||||
/// callers can fall back to declaration-driven UDT fan-out.
|
||||
/// </summary>
|
||||
internal async Task<AbCipUdtShape?> FetchUdtShapeAsync(
|
||||
string deviceHostAddress, uint templateInstanceId, CancellationToken cancellationToken)
|
||||
{
|
||||
var cached = _templateCache.TryGet(deviceHostAddress, templateInstanceId);
|
||||
if (cached is not null) return cached;
|
||||
|
||||
if (!_devices.TryGetValue(deviceHostAddress, out var device)) return null;
|
||||
|
||||
var deviceParams = new AbCipTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: $"@udt/{templateInstanceId}",
|
||||
Timeout: _options.Timeout);
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = _templateReaderFactory.Create();
|
||||
var buffer = await reader.ReadAsync(deviceParams, templateInstanceId, cancellationToken).ConfigureAwait(false);
|
||||
var shape = CipTemplateObjectDecoder.Decode(buffer);
|
||||
if (shape is not null)
|
||||
_templateCache.Put(deviceHostAddress, templateInstanceId, shape);
|
||||
return shape;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch
|
||||
{
|
||||
// Template read failure — log via the driver's health surface so operators see it,
|
||||
// but don't propagate since callers should fall back to declaration-driven UDT
|
||||
// semantics rather than failing the whole discovery run.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shared UDT template cache. Exposed for PR 6 (UDT reader) + diagnostics.</summary>
|
||||
internal AbCipTemplateCache TemplateCache => _templateCache;
|
||||
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
public string DriverType => "AbCip";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var addr = AbCipHostAddress.TryParse(device.HostAddress)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AbCip device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
|
||||
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
|
||||
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
||||
}
|
||||
foreach (var tag in _options.Tags)
|
||||
{
|
||||
_tagsByName[tag.Name] = tag;
|
||||
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
||||
{
|
||||
foreach (var member in tag.Members)
|
||||
{
|
||||
var memberTag = new AbCipTagDefinition(
|
||||
Name: $"{tag.Name}.{member.Name}",
|
||||
DeviceHostAddress: tag.DeviceHostAddress,
|
||||
TagPath: $"{tag.TagPath}.{member.Name}",
|
||||
DataType: member.DataType,
|
||||
Writable: member.Writable,
|
||||
WriteIdempotent: member.WriteIdempotent);
|
||||
_tagsByName[memberTag.Name] = memberTag;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Probe loops — one per device when enabled + a ProbeTagPath is configured.
|
||||
if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeTagPath))
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
state.ProbeCts = new CancellationTokenSource();
|
||||
var ct = state.ProbeCts.Token;
|
||||
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
|
||||
}
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
throw;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
try { state.ProbeCts?.Cancel(); } catch { }
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
state.DisposeHandles();
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
_poll.Unsubscribe(handle);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
|
||||
|
||||
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
|
||||
{
|
||||
var probeParams = new AbCipTagCreateParams(
|
||||
Gateway: state.ParsedAddress.Gateway,
|
||||
Port: state.ParsedAddress.Port,
|
||||
CipPath: state.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
||||
TagName: _options.Probe.ProbeTagPath!,
|
||||
Timeout: _options.Probe.Timeout);
|
||||
|
||||
IAbCipTagRuntime? probeRuntime = null;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
probeRuntime ??= _tagFactory.Create(probeParams);
|
||||
// Lazy-init on first attempt; re-init after a transport failure has caused the
|
||||
// native handle to be destroyed.
|
||||
if (!state.ProbeInitialized)
|
||||
{
|
||||
await probeRuntime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
state.ProbeInitialized = true;
|
||||
}
|
||||
await probeRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||
success = probeRuntime.GetStatus() == 0;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Wire / init error — tear down the probe runtime so the next tick re-creates it.
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
probeRuntime = null;
|
||||
state.ProbeInitialized = false;
|
||||
}
|
||||
|
||||
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||||
|
||||
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
lock (state.ProbeLock)
|
||||
{
|
||||
old = state.HostState;
|
||||
if (old == newState) return;
|
||||
state.HostState = newState;
|
||||
state.HostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
OnHostStatusChanged?.Invoke(this,
|
||||
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the device host address for a given tag full-reference. Per plan decision #144
|
||||
/// the Phase 6.1 resilience pipeline keys its bulkhead + breaker on
|
||||
/// <c>(DriverInstanceId, hostName)</c> so multi-PLC drivers get per-device isolation —
|
||||
/// one dead PLC trips only its own breaker. Unknown references fall back to the
|
||||
/// first configured device's host address rather than throwing — the invoker handles the
|
||||
/// mislookup at the capability level when the actual read returns BadNodeIdUnknown.
|
||||
/// </summary>
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||
return def.DeviceHostAddress;
|
||||
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
||||
}
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
/// <summary>
|
||||
/// Read each <c>fullReference</c> in order. Unknown tags surface as
|
||||
/// <c>BadNodeIdUnknown</c>; libplctag-layer failures map through
|
||||
/// <see cref="AbCipStatusMapper.MapLibplctagStatus"/>; any other exception becomes
|
||||
/// <c>BadCommunicationError</c>. The driver health surface is updated per-call so the
|
||||
/// Admin UI sees a tight feedback loop between read failures + the driver's state.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading {reference}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
var bitIndex = tagPath?.BitIndex;
|
||||
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
||||
results[i] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
/// <summary>
|
||||
/// Write each request in order. Writes are NOT auto-retried by the driver — per plan
|
||||
/// decisions #44, #45, #143 the caller opts in via <see cref="AbCipTagDefinition.WriteIdempotent"/>
|
||||
/// and the resilience pipeline (layered above the driver) decides whether to replay.
|
||||
/// Non-writable configurations surface as <c>BadNotWritable</c>; type-conversion failures
|
||||
/// as <c>BadTypeMismatch</c>; transport errors as <c>BadCommunicationError</c>.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
var results = new WriteResult[writes.Count];
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
if (!_tagsByName.TryGetValue(w.FullReference, out var def))
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!def.Writable || def.SafetyTag)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
|
||||
// BOOL-within-DINT writes — per task #181, RMW against a parallel parent-DINT
|
||||
// runtime. Dispatching here keeps the normal EncodeValue path clean; the
|
||||
// per-parent lock prevents two concurrent bit writes to the same DINT from
|
||||
// losing one another's update.
|
||||
if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit)
|
||||
{
|
||||
results[i] = new WriteResult(
|
||||
await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
if (results[i].StatusCode == AbCipStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
|
||||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
results[i] = new WriteResult(status == 0
|
||||
? AbCipStatusMapper.Good
|
||||
: AbCipStatusMapper.MapLibplctagStatus(status));
|
||||
if (status == 0) _health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
}
|
||||
catch (FormatException fe)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
|
||||
}
|
||||
catch (InvalidCastException ice)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
|
||||
}
|
||||
catch (OverflowException oe)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
|
||||
/// parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent
|
||||
/// writers against the same parent via a per-parent <see cref="SemaphoreSlim"/>.
|
||||
/// Matches the Modbus BitInRegister + FOCAS PMC Bit pattern shipped in pass 1 of task #181.
|
||||
/// </summary>
|
||||
private async Task<uint> WriteBitInDIntAsync(
|
||||
DeviceState device, AbCipTagPath bitPath, int bit, object? value, CancellationToken ct)
|
||||
{
|
||||
var parentPath = bitPath with { BitIndex = null };
|
||||
var parentName = parentPath.ToLibplctagName();
|
||||
|
||||
var rmwLock = device.GetRmwLock(parentName);
|
||||
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
|
||||
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||
var readStatus = parentRuntime.GetStatus();
|
||||
if (readStatus != 0) return AbCipStatusMapper.MapLibplctagStatus(readStatus);
|
||||
|
||||
var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0);
|
||||
var updated = Convert.ToBoolean(value)
|
||||
? current | (1 << bit)
|
||||
: current & ~(1 << bit);
|
||||
|
||||
parentRuntime.EncodeValue(AbCipDataType.DInt, bitIndex: null, updated);
|
||||
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
|
||||
var writeStatus = parentRuntime.GetStatus();
|
||||
return writeStatus == 0
|
||||
? AbCipStatusMapper.Good
|
||||
: AbCipStatusMapper.MapLibplctagStatus(writeStatus);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rmwLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get or lazily create a parent-DINT runtime for a parent tag path, cached per-device
|
||||
/// so repeated bit writes against the same DINT share one handle.
|
||||
/// </summary>
|
||||
private async Task<IAbCipTagRuntime> EnsureParentRuntimeAsync(
|
||||
DeviceState device, string parentTagName, CancellationToken ct)
|
||||
{
|
||||
if (device.ParentRuntimes.TryGetValue(parentTagName, out var existing)) return existing;
|
||||
|
||||
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parentTagName,
|
||||
Timeout: _options.Timeout));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
device.ParentRuntimes[parentTagName] = runtime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently materialise the runtime handle for a tag definition. First call creates
|
||||
/// + initialises the libplctag Tag; subsequent calls reuse the cached handle for the
|
||||
/// lifetime of the device.
|
||||
/// </summary>
|
||||
private async Task<IAbCipTagRuntime> EnsureTagRuntimeAsync(
|
||||
DeviceState device, AbCipTagDefinition def, CancellationToken ct)
|
||||
{
|
||||
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
||||
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'.");
|
||||
|
||||
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parsed.ToLibplctagName(),
|
||||
Timeout: _options.Timeout));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
device.Runtimes[def.Name] = runtime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
|
||||
/// <summary>
|
||||
/// CLR-visible allocation footprint only — libplctag's native heap is invisible to the
|
||||
/// GC. driver-specs.md §3 flags this: operators must watch whole-process RSS for the
|
||||
/// full picture, and <see cref="ReinitializeAsync"/> is the Tier-B remediation.
|
||||
/// </summary>
|
||||
public long GetMemoryFootprint() => 0;
|
||||
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_templateCache.Clear();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
/// <summary>
|
||||
/// Stream the driver's tag set into the builder. Pre-declared tags from
|
||||
/// <see cref="AbCipDriverOptions.Tags"/> emit first; optionally, the
|
||||
/// <see cref="IAbCipTagEnumerator"/> walks each device's symbol table and adds
|
||||
/// controller-discovered tags under a <c>Discovered/</c> sub-folder. System / module /
|
||||
/// routine / task tags are hidden via <see cref="AbCipSystemTagFilter"/>.
|
||||
/// </summary>
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var root = builder.Folder("AbCip", "AbCip");
|
||||
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var deviceLabel = device.DeviceName ?? device.HostAddress;
|
||||
var deviceFolder = root.Folder(device.HostAddress, deviceLabel);
|
||||
|
||||
// Pre-declared tags — always emitted; the primary config path. UDT tags with declared
|
||||
// Members fan out into a sub-folder + one Variable per member instead of a single
|
||||
// Structure Variable (Structure has no useful scalar value + member-addressable paths
|
||||
// are what downstream consumers actually want).
|
||||
var preDeclared = _options.Tags.Where(t =>
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in preDeclared)
|
||||
{
|
||||
if (AbCipSystemTagFilter.IsSystemTag(tag.Name)) continue;
|
||||
|
||||
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
||||
{
|
||||
var udtFolder = deviceFolder.Folder(tag.Name, tag.Name);
|
||||
foreach (var member in tag.Members)
|
||||
{
|
||||
var memberFullName = $"{tag.Name}.{member.Name}";
|
||||
udtFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
|
||||
FullName: memberFullName,
|
||||
DriverDataType: member.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: member.Writable
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: member.WriteIdempotent));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag));
|
||||
}
|
||||
|
||||
// Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags
|
||||
// walker (LibplctagTagEnumerator) is the factory default since task #178 shipped,
|
||||
// so leaving the flag off keeps the strict-config path for deployments where only
|
||||
// declared tags should appear.
|
||||
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
|
||||
{
|
||||
using var enumerator = _enumeratorFactory.Create();
|
||||
var deviceParams = new AbCipTagCreateParams(
|
||||
Gateway: state.ParsedAddress.Gateway,
|
||||
Port: state.ParsedAddress.Port,
|
||||
CipPath: state.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
||||
TagName: "@tags",
|
||||
Timeout: _options.Timeout);
|
||||
|
||||
IAddressSpaceBuilder? discoveredFolder = null;
|
||||
await foreach (var discovered in enumerator.EnumerateAsync(deviceParams, cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
if (discovered.IsSystemTag) continue;
|
||||
if (AbCipSystemTagFilter.IsSystemTag(discovered.Name)) continue;
|
||||
|
||||
discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered");
|
||||
var fullName = discovered.ProgramScope is null
|
||||
? discovered.Name
|
||||
: $"Program:{discovered.ProgramScope}.{discovered.Name}";
|
||||
discoveredFolder.Variable(fullName, discovered.Name, new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: discovered.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: discovered.ReadOnly
|
||||
? SecurityClassification.ViewOnly
|
||||
: SecurityClassification.Operate,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: (tag.Writable && !tag.SafetyTag)
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent);
|
||||
|
||||
/// <summary>Count of registered devices — exposed for diagnostics + tests.</summary>
|
||||
internal int DeviceCount => _devices.Count;
|
||||
|
||||
/// <summary>Looked-up device state for the given host address. Tests + later-PR capabilities hit this.</summary>
|
||||
internal DeviceState? GetDeviceState(string hostAddress) =>
|
||||
_devices.TryGetValue(hostAddress, out var s) ? s : null;
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-device runtime state. Holds the parsed host address, family profile, and the
|
||||
/// live <see cref="PlcTagHandle"/> cache keyed by tag path. PRs 3–8 populate + consume
|
||||
/// this dict via libplctag.
|
||||
/// </summary>
|
||||
internal sealed class DeviceState(
|
||||
AbCipHostAddress parsedAddress,
|
||||
AbCipDeviceOptions options,
|
||||
AbCipPlcFamilyProfile profile)
|
||||
{
|
||||
public AbCipHostAddress ParsedAddress { get; } = parsedAddress;
|
||||
public AbCipDeviceOptions Options { get; } = options;
|
||||
public AbCipPlcFamilyProfile Profile { get; } = profile;
|
||||
|
||||
public object ProbeLock { get; } = new();
|
||||
public HostState HostState { get; set; } = HostState.Unknown;
|
||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
public bool ProbeInitialized { get; set; }
|
||||
|
||||
public Dictionary<string, PlcTagHandle> TagHandles { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Per-tag runtime handles owned by this device. One entry per configured tag is
|
||||
/// created lazily on first read (see <see cref="AbCipDriver.EnsureTagRuntimeAsync"/>).
|
||||
/// </summary>
|
||||
public Dictionary<string, IAbCipTagRuntime> Runtimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Parent-DINT runtimes created on-demand by <see cref="AbCipDriver.EnsureParentRuntimeAsync"/>
|
||||
/// for BOOL-within-DINT RMW writes. Separate from <see cref="Runtimes"/> because a
|
||||
/// bit-selector tag name ("Motor.Flags.3") needs a distinct handle from the DINT
|
||||
/// parent ("Motor.Flags") used to do the read + write.
|
||||
/// </summary>
|
||||
public Dictionary<string, IAbCipTagRuntime> ParentRuntimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
||||
|
||||
public SemaphoreSlim GetRmwLock(string parentTagName) =>
|
||||
_rmwLocks.GetOrAdd(parentTagName, _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
public void DisposeHandles()
|
||||
{
|
||||
foreach (var h in TagHandles.Values) h.Dispose();
|
||||
TagHandles.Clear();
|
||||
foreach (var r in Runtimes.Values) r.Dispose();
|
||||
Runtimes.Clear();
|
||||
foreach (var r in ParentRuntimes.Values) r.Dispose();
|
||||
ParentRuntimes.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
125
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
Normal file
125
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// AB CIP / EtherNet-IP driver configuration, bound from the driver's <c>DriverConfig</c>
|
||||
/// JSON at <c>DriverHost.RegisterAsync</c>. One instance supports N devices (PLCs) behind
|
||||
/// the same driver; per-device routing is keyed on <see cref="AbCipDeviceOptions.HostAddress"/>
|
||||
/// via <c>IPerCallHostResolver</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per v2 plan decisions #11 (libplctag), #41 (AbCip vs AbLegacy split), #143–144 (per-call
|
||||
/// host resolver + resilience keys), #144 (bulkhead keyed on <c>(DriverInstanceId, HostName)</c>).
|
||||
/// </remarks>
|
||||
public sealed class AbCipDriverOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// PLCs this driver instance talks to. Each device contributes its own <see cref="AbCipHostAddress"/>
|
||||
/// string as the <c>hostName</c> key used by resilience pipelines and the Admin UI.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AbCipDeviceOptions> Devices { get; init; } = [];
|
||||
|
||||
/// <summary>Pre-declared tag map across all devices — AB discovery lands in PR 5.</summary>
|
||||
public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
|
||||
public AbCipProbeOptions Probe { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Default libplctag call timeout applied to reads/writes/discovery when the caller does
|
||||
/// not pass a more specific value. Matches the Modbus driver's 2-second default.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, <c>DiscoverAsync</c> walks each device's Logix symbol table via
|
||||
/// the <c>@tags</c> pseudo-tag + surfaces controller-resident globals under a
|
||||
/// <c>Discovered/</c> sub-folder. Pre-declared tags always emit regardless. Default
|
||||
/// <c>false</c> to keep the strict-config path for deployments where only declared tags
|
||||
/// should appear in the address space.
|
||||
/// </summary>
|
||||
public bool EnableControllerBrowse { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One PLC endpoint. <see cref="HostAddress"/> must parse via
|
||||
/// <see cref="AbCipHostAddress.TryParse"/>; misconfigured devices fail driver
|
||||
/// initialization rather than silently connecting to nothing.
|
||||
/// </summary>
|
||||
/// <param name="HostAddress">Canonical <c>ab://gateway[:port]/cip-path</c> string.</param>
|
||||
/// <param name="PlcFamily">Which per-family profile to apply. Determines ConnectionSize,
|
||||
/// request-packing support, unconnected-only hint, and other quirks.</param>
|
||||
/// <param name="DeviceName">Optional display label for Admin UI. Falls back to <see cref="HostAddress"/>.</param>
|
||||
public sealed record AbCipDeviceOptions(
|
||||
string HostAddress,
|
||||
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
|
||||
string? DeviceName = null);
|
||||
|
||||
/// <summary>
|
||||
/// One AB-backed OPC UA variable. Mirrors the <c>ModbusTagDefinition</c> shape.
|
||||
/// </summary>
|
||||
/// <param name="Name">Tag name; becomes the OPC UA browse name and full reference.</param>
|
||||
/// <param name="DeviceHostAddress">Which device (<see cref="AbCipDeviceOptions.HostAddress"/>) this tag lives on.</param>
|
||||
/// <param name="TagPath">Logix symbolic path (controller or program scope).</param>
|
||||
/// <param name="DataType">Logix atomic type, or <see cref="AbCipDataType.Structure"/> for UDT-typed tags.</param>
|
||||
/// <param name="Writable">When <c>true</c> and the tag's ExternalAccess permits writes, IWritable routes writes here.</param>
|
||||
/// <param name="WriteIdempotent">Per plan decisions #44–#45, #143 — safe to replay on write timeout. Default <c>false</c>.</param>
|
||||
/// <param name="Members">For <see cref="AbCipDataType.Structure"/>-typed tags, the declared UDT
|
||||
/// member layout. When supplied, discovery fans out the UDT into a folder + one Variable per
|
||||
/// member (member TagPath = <c>{tag.TagPath}.{member.Name}</c>). When <c>null</c> on a Structure
|
||||
/// tag, the driver treats it as a black-box and relies on downstream configuration to address
|
||||
/// members individually via dotted <see cref="AbCipTagPath"/> syntax. Ignored for atomic types.</param>
|
||||
/// <param name="SafetyTag">GuardLogix safety-partition tag hint. When <c>true</c>, the driver
|
||||
/// forces <c>SecurityClassification.ViewOnly</c> on discovery regardless of
|
||||
/// <paramref name="Writable"/> — safety tags can only be written from the safety task of a
|
||||
/// GuardLogix controller; non-safety writes violate the safety-partition isolation and are
|
||||
/// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the
|
||||
/// write attempt failing at runtime.</param>
|
||||
public sealed record AbCipTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
string TagPath,
|
||||
AbCipDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false,
|
||||
IReadOnlyList<AbCipStructureMember>? Members = null,
|
||||
bool SafetyTag = false);
|
||||
|
||||
/// <summary>
|
||||
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
||||
/// <c>Status</c>), DataType is the atomic Logix type, Writable/WriteIdempotent mirror
|
||||
/// <see cref="AbCipTagDefinition"/>. Declaration-driven — the real CIP Template Object reader
|
||||
/// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR.
|
||||
/// </summary>
|
||||
public sealed record AbCipStructureMember(
|
||||
string Name,
|
||||
AbCipDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
|
||||
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
|
||||
public enum AbCipPlcFamily
|
||||
{
|
||||
ControlLogix,
|
||||
CompactLogix,
|
||||
Micro800,
|
||||
GuardLogix,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background connectivity-probe settings. Enabled by default; the probe reads a cheap tag
|
||||
/// on the PLC at the configured interval to drive <see cref="Core.Abstractions.IHostConnectivityProbe"/>
|
||||
/// state transitions + Admin UI health status.
|
||||
/// </summary>
|
||||
public sealed class AbCipProbeOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Tag path used for the probe. If null, the driver attempts to read a default
|
||||
/// system tag (PR 8 wires this up — the choice is family-dependent, e.g.
|
||||
/// <c>@raw_cpu_type</c> on ControlLogix or a user-configured probe tag on Micro800).
|
||||
/// </summary>
|
||||
public string? ProbeTagPath { get; init; }
|
||||
}
|
||||
68
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipHostAddress.cs
Normal file
68
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipHostAddress.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed <c>ab://gateway[:port]/cip-path</c> host-address string used by the AbCip driver
|
||||
/// as the <c>hostName</c> key across <see cref="Core.Abstractions.IHostConnectivityProbe"/>,
|
||||
/// <see cref="Core.Abstractions.IPerCallHostResolver"/>, and the Polly bulkhead key
|
||||
/// <c>(DriverInstanceId, hostName)</c> per v2 plan decision #144.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Format matches what libplctag's <c>gateway=...</c> + <c>path=...</c> attributes
|
||||
/// consume, so no translation is needed at the wire layer — the parsed <see cref="CipPath"/>
|
||||
/// is handed to the native library verbatim.</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>ab://10.0.0.5/1,0</c> — single-chassis ControlLogix, CPU in slot 0.</item>
|
||||
/// <item><c>ab://10.0.0.5/1,4</c> — CPU in slot 4.</item>
|
||||
/// <item><c>ab://10.0.0.5/1,2,2,192.168.50.20,1,0</c> — bridged ControlLogix.</item>
|
||||
/// <item><c>ab://10.0.0.5/</c> (empty path) — Micro800 / MicroLogix without backplane routing.</item>
|
||||
/// <item><c>ab://10.0.0.5:44818/1,0</c> — explicit EIP port (default 44818).</item>
|
||||
/// </list>
|
||||
/// <para>Opaque to the rest of the stack: Admin UI, telemetry, and logs display the full
|
||||
/// string so an incident ticket can be matched to the exact gateway + CIP route.</para>
|
||||
/// </remarks>
|
||||
public sealed record AbCipHostAddress(string Gateway, int Port, string CipPath)
|
||||
{
|
||||
/// <summary>Default EtherNet/IP TCP port — spec-reserved.</summary>
|
||||
public const int DefaultEipPort = 44818;
|
||||
|
||||
/// <summary>Recompose the canonical <c>ab://...</c> form.</summary>
|
||||
public override string ToString() => Port == DefaultEipPort
|
||||
? $"ab://{Gateway}/{CipPath}"
|
||||
: $"ab://{Gateway}:{Port}/{CipPath}";
|
||||
|
||||
/// <summary>
|
||||
/// Parse <paramref name="value"/>. Returns <c>null</c> on any malformed input — callers
|
||||
/// should treat a null return as a config-validation failure rather than catching.
|
||||
/// </summary>
|
||||
public static AbCipHostAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
const string prefix = "ab://";
|
||||
if (!value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
var remainder = value[prefix.Length..];
|
||||
var slashIdx = remainder.IndexOf('/');
|
||||
if (slashIdx < 0) return null;
|
||||
|
||||
var authority = remainder[..slashIdx];
|
||||
var cipPath = remainder[(slashIdx + 1)..];
|
||||
if (string.IsNullOrEmpty(authority)) return null;
|
||||
|
||||
var port = DefaultEipPort;
|
||||
var colonIdx = authority.LastIndexOf(':');
|
||||
string gateway;
|
||||
if (colonIdx >= 0)
|
||||
{
|
||||
gateway = authority[..colonIdx];
|
||||
if (!int.TryParse(authority[(colonIdx + 1)..], out port) || port <= 0 || port > 65535)
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
gateway = authority;
|
||||
}
|
||||
if (string.IsNullOrEmpty(gateway)) return null;
|
||||
|
||||
return new AbCipHostAddress(gateway, port, cipPath);
|
||||
}
|
||||
}
|
||||
79
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs
Normal file
79
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Maps libplctag / CIP General Status codes to OPC UA StatusCodes. Mirrors the shape of
|
||||
/// <c>ModbusDriver.MapModbusExceptionToStatus</c> so Admin UI status displays stay
|
||||
/// uniform across drivers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Coverage: the CIP general-status values an AB PLC actually returns during normal
|
||||
/// driver operation. Full CIP Volume 1 Appendix B lists 50+ codes; the ones here are the
|
||||
/// ones that move the driver's status needle:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>0x00 success — OPC UA <c>Good (0)</c>.</item>
|
||||
/// <item>0x04 path segment error / 0x05 path destination unknown — <c>BadNodeIdUnknown</c>
|
||||
/// (tag doesn't exist).</item>
|
||||
/// <item>0x06 partial data transfer — <c>GoodMoreData</c> (fragmented read underway).</item>
|
||||
/// <item>0x08 service not supported — <c>BadNotSupported</c> (e.g. write on a safety
|
||||
/// partition tag from a non-safety task).</item>
|
||||
/// <item>0x0A / 0x13 attribute-list error / insufficient data — <c>BadOutOfRange</c>
|
||||
/// (type mismatch or truncated buffer).</item>
|
||||
/// <item>0x0B already in requested mode — benign, treated as <c>Good</c>.</item>
|
||||
/// <item>0x0E attribute not settable — <c>BadNotWritable</c>.</item>
|
||||
/// <item>0x10 device state conflict — <c>BadDeviceFailure</c> (program-mode protected
|
||||
/// writes during download / test-mode transitions).</item>
|
||||
/// <item>0x16 object does not exist — <c>BadNodeIdUnknown</c>.</item>
|
||||
/// <item>0x1E embedded service error — unwrap to the extended status when possible.</item>
|
||||
/// <item>any libplctag <c>PLCTAG_STATUS_*</c> below zero — wrapped as
|
||||
/// <c>BadCommunicationError</c> until fine-grained mapping lands (PR 3).</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static class AbCipStatusMapper
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
public const uint GoodMoreData = 0x00A70000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
public const uint BadOutOfRange = 0x803C0000u;
|
||||
public const uint BadNotSupported = 0x803D0000u;
|
||||
public const uint BadDeviceFailure = 0x80550000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
|
||||
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
|
||||
public static uint MapCipGeneralStatus(byte status) => status switch
|
||||
{
|
||||
0x00 => Good,
|
||||
0x04 or 0x05 => BadNodeIdUnknown,
|
||||
0x06 => GoodMoreData,
|
||||
0x08 => BadNotSupported,
|
||||
0x0A or 0x13 => BadOutOfRange,
|
||||
0x0B => Good,
|
||||
0x0E => BadNotWritable,
|
||||
0x10 => BadDeviceFailure,
|
||||
0x16 => BadNodeIdUnknown,
|
||||
_ => BadInternalError,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Map a libplctag return/status code (<c>PLCTAG_STATUS_*</c>) to an OPC UA StatusCode.
|
||||
/// libplctag uses <c>0 = PLCTAG_STATUS_OK</c>, positive values for pending, negative
|
||||
/// values for errors.
|
||||
/// </summary>
|
||||
public static uint MapLibplctagStatus(int status)
|
||||
{
|
||||
if (status == 0) return Good;
|
||||
if (status > 0) return GoodMoreData; // PLCTAG_STATUS_PENDING
|
||||
return status switch
|
||||
{
|
||||
-5 => BadTimeout, // PLCTAG_ERR_TIMEOUT
|
||||
-7 => BadCommunicationError, // PLCTAG_ERR_BAD_CONNECTION
|
||||
-14 => BadNodeIdUnknown, // PLCTAG_ERR_NOT_FOUND
|
||||
-16 => BadNotWritable, // PLCTAG_ERR_NOT_ALLOWED / read-only tag
|
||||
-17 => BadOutOfRange, // PLCTAG_ERR_OUT_OF_BOUNDS
|
||||
_ => BadCommunicationError,
|
||||
};
|
||||
}
|
||||
}
|
||||
49
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagFilter.cs
Normal file
49
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagFilter.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Filters system / infrastructure tags out of discovered tag sets. A Logix controller's
|
||||
/// symbol table exposes user tags alongside module-config objects, routine pointers, task
|
||||
/// pointers, and <c>__DEFVAL_*</c> stubs that are noise for the OPC UA address space.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lifted from the filter conventions documented across Rockwell Knowledgebase article
|
||||
/// IC-12345 and the Logix 5000 Controllers General Instructions Reference. The list is
|
||||
/// conservative — when in doubt, a tag is surfaced rather than hidden so operators can
|
||||
/// see it and the config flow can explicitly hide it via UnsArea ACL.
|
||||
/// </remarks>
|
||||
public static class AbCipSystemTagFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>true</c> when the tag name matches a well-known system-tag pattern the driver
|
||||
/// should hide from the default address space. Case-sensitive — Logix symbols are
|
||||
/// always preserved case and the system-tag prefixes are uppercase by convention.
|
||||
/// </summary>
|
||||
public static bool IsSystemTag(string tagName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagName)) return true;
|
||||
|
||||
// Internal backing store for tag defaults — never user-meaningful.
|
||||
if (tagName.StartsWith("__DEFVAL_", StringComparison.Ordinal)) return true;
|
||||
if (tagName.StartsWith("__DEFAULT_", StringComparison.Ordinal)) return true;
|
||||
|
||||
// Routine and Task pointer pseudo-tags.
|
||||
if (tagName.StartsWith("Routine:", StringComparison.Ordinal)) return true;
|
||||
if (tagName.StartsWith("Task:", StringComparison.Ordinal)) return true;
|
||||
|
||||
// Logix module-config auto-generated names — Local:1:I, Local:1:O, etc. Module data is
|
||||
// exposed separately via the dedicated hardware mapping; the auto-generated symbol-table
|
||||
// entries duplicate that.
|
||||
if (tagName.StartsWith("Local:", StringComparison.Ordinal) && tagName.Contains(':')) return true;
|
||||
|
||||
// Map / Mapped IO alias tags (MainProgram.MapName pattern — dot-separated but prefixed
|
||||
// with a reserved colon-carrying prefix to avoid false positives on user member access).
|
||||
if (tagName.StartsWith("Map:", StringComparison.Ordinal)) return true;
|
||||
|
||||
// Axis / Cam / Motion-Group predefined structures — exposed separately through motion API.
|
||||
if (tagName.StartsWith("Axis:", StringComparison.Ordinal)) return true;
|
||||
if (tagName.StartsWith("Cam:", StringComparison.Ordinal)) return true;
|
||||
if (tagName.StartsWith("MotionGroup:", StringComparison.Ordinal)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user