Add component-level documentation for all 14 server subsystems
Provides technical documentation covering OPC UA server, address space, Galaxy repository, MXAccess bridge, data types, read/write, subscriptions, alarms, historian, incremental sync, configuration, dashboard, service hosting, and CLI tool. Updates README with component documentation table. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
183
README.md
183
README.md
@@ -12,19 +12,21 @@ OPC UA server on .NET Framework 4.8 (x86) that exposes AVEVA System Platform (Wo
|
|||||||
| Galaxy Repo DB |---->| OPC UA Server |<--->| MXAccess Client |
|
| Galaxy Repo DB |---->| OPC UA Server |<--->| MXAccess Client |
|
||||||
| (SQL Server) | | (address space) | | (STA + COM) |
|
| (SQL Server) | | (address space) | | (STA + COM) |
|
||||||
+-----------------+ +------------------+ +-----------------+
|
+-----------------+ +------------------+ +-----------------+
|
||||||
|
|
| |
|
||||||
+-------+--------+
|
+-------+--------+ +---------+---------+
|
||||||
| Status Dashboard|
|
| Status Dashboard| | Historian Runtime |
|
||||||
| (HTTP/JSON) |
|
| (HTTP/JSON) | | (SQL Server) |
|
||||||
+----------------+
|
+----------------+ +-------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
**Galaxy Repository** queries the ZB database for the deployed object hierarchy and attribute definitions, building the OPC UA address space at startup and rebuilding on deploy changes.
|
**Galaxy Repository** queries the ZB database for the deployed object hierarchy and attribute definitions, building the OPC UA address space at startup and incrementally syncing on deploy changes.
|
||||||
|
|
||||||
**MXAccess Client** connects to the Galaxy runtime via COM interop on a dedicated STA thread with a Win32 message pump. Handles subscriptions, read/write, reconnection, and probe-based health monitoring.
|
**MXAccess Client** connects to the Galaxy runtime via COM interop on a dedicated STA thread with a Win32 message pump. Handles subscriptions, read/write, reconnection, and probe-based health monitoring.
|
||||||
|
|
||||||
**OPC UA Server** exposes the hierarchy as browse nodes (folders for areas, objects for containers) with variable nodes for each attribute. Clients browse using contained names but reads/writes translate to `tag_name.AttributeName` for MXAccess.
|
**OPC UA Server** exposes the hierarchy as browse nodes (folders for areas, objects for containers) with variable nodes for each attribute. Clients browse using contained names but reads/writes translate to `tag_name.AttributeName` for MXAccess.
|
||||||
|
|
||||||
|
**Historian Runtime** provides historical data access via SQL queries against the Wonderware Historian `Runtime` database, serving OPC UA `HistoryRead` requests for raw and aggregate data.
|
||||||
|
|
||||||
## Contained Name vs Tag Name
|
## Contained Name vs Tag Name
|
||||||
|
|
||||||
| Browse Path (contained names) | Runtime Reference (tag name) |
|
| Browse Path (contained names) | Runtime Reference (tag name) |
|
||||||
@@ -40,8 +42,9 @@ OPC UA server on .NET Framework 4.8 (x86) that exposes AVEVA System Platform (Wo
|
|||||||
- AVEVA System Platform with ArchestrA Framework installed
|
- AVEVA System Platform with ArchestrA Framework installed
|
||||||
- Galaxy repository database (SQL Server, Windows Auth)
|
- Galaxy repository database (SQL Server, Windows Auth)
|
||||||
- MXAccess COM registered (`LMXProxy.LMXProxyServer`)
|
- MXAccess COM registered (`LMXProxy.LMXProxyServer`)
|
||||||
|
- Wonderware Historian (optional, for historical data access)
|
||||||
|
|
||||||
### Build & Run
|
### Build and run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet restore ZB.MOM.WW.LmxOpcUa.slnx
|
dotnet restore ZB.MOM.WW.LmxOpcUa.slnx
|
||||||
@@ -51,7 +54,7 @@ dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Host
|
|||||||
|
|
||||||
The server starts on `opc.tcp://localhost:4840/LmxOpcUa` with SecurityPolicy None.
|
The server starts on `opc.tcp://localhost:4840/LmxOpcUa` with SecurityPolicy None.
|
||||||
|
|
||||||
### Install as Windows Service
|
### Install as Windows service
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src/ZB.MOM.WW.LmxOpcUa.Host/bin/Debug/net48
|
cd src/ZB.MOM.WW.LmxOpcUa.Host/bin/Debug/net48
|
||||||
@@ -59,7 +62,7 @@ ZB.MOM.WW.LmxOpcUa.Host.exe install
|
|||||||
ZB.MOM.WW.LmxOpcUa.Host.exe start
|
ZB.MOM.WW.LmxOpcUa.Host.exe start
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test with CLI Tool
|
### Test with CLI tool
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Connect
|
# Connect
|
||||||
@@ -76,60 +79,78 @@ dotnet run --project tools/opcuacli-dotnet -- write -u opc.tcp://localhost:4840/
|
|||||||
|
|
||||||
# Subscribe to changes
|
# Subscribe to changes
|
||||||
dotnet run --project tools/opcuacli-dotnet -- subscribe -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestChildObject.TestInt" -i 500
|
dotnet run --project tools/opcuacli-dotnet -- subscribe -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestChildObject.TestInt" -i 500
|
||||||
|
|
||||||
|
# Read historical data
|
||||||
|
dotnet run --project tools/opcuacli-dotnet -- historyread -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.TestHistoryValue" --start "2026-03-25" --end "2026-03-30"
|
||||||
|
|
||||||
|
# Subscribe to alarm events
|
||||||
|
dotnet run --project tools/opcuacli-dotnet -- alarms -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001" --refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run Tests
|
### Run tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet test ZB.MOM.WW.LmxOpcUa.slnx
|
dotnet test ZB.MOM.WW.LmxOpcUa.slnx
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Security Classification
|
||||||
|
|
||||||
|
Galaxy attributes carry a `security_classification` that controls OPC UA write access. The server maps these to `AccessLevel` on each variable node:
|
||||||
|
|
||||||
|
| Classification | Galaxy Level | OPC UA Access |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | FreeAccess | ReadWrite |
|
||||||
|
| 1 | Operate | ReadWrite |
|
||||||
|
| 2 | SecuredWrite | ReadOnly |
|
||||||
|
| 3 | VerifiedWrite | ReadOnly |
|
||||||
|
| 4 | Tune | ReadWrite |
|
||||||
|
| 5 | Configure | ReadWrite |
|
||||||
|
| 6 | ViewOnly | ReadOnly |
|
||||||
|
|
||||||
|
See `gr/data_type_mapping.md` for the full mapping table.
|
||||||
|
|
||||||
|
## Alarm Tracking
|
||||||
|
|
||||||
|
When `AlarmTrackingEnabled` is true, the server creates `AlarmConditionState` nodes for Galaxy attributes with `AlarmExtension` primitives. It auto-subscribes to each alarm's `InAlarm` tag and reports OPC UA alarm events on state transitions:
|
||||||
|
|
||||||
|
- `InAlarm` TRUE → alarm active event (severity from `Priority`, message from `DescAttrName`)
|
||||||
|
- `InAlarm` FALSE → alarm cleared event
|
||||||
|
- Condition refresh reports all currently retained alarms to newly subscribing clients
|
||||||
|
|
||||||
|
## Historical Data Access
|
||||||
|
|
||||||
|
When `Historian.Enabled` is true, the server handles OPC UA `HistoryRead` requests by querying the Wonderware Historian `Runtime` database:
|
||||||
|
|
||||||
|
- **Raw reads** query `Runtime.dbo.History` with time range and max values
|
||||||
|
- **Processed reads** query `Runtime.dbo.AnalogSummaryHistory` with aggregates (Average, Minimum, Maximum, Count)
|
||||||
|
- Historized variables advertise `Historizing=true` and `AccessLevel` includes `HistoryRead`
|
||||||
|
|
||||||
|
## Incremental Address Space Sync
|
||||||
|
|
||||||
|
When the Galaxy detects a new deployment, the server compares the previous hierarchy and attributes against the new snapshot. Only changed gobject subtrees are torn down and rebuilt — unchanged nodes, subscriptions, and alarm tracking remain intact. See `partial_update.md` for details.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All settings in `appsettings.json`, overridable via environment variables:
|
All settings in `appsettings.json`, overridable via environment variables:
|
||||||
|
|
||||||
| Section | Key | Default | Description |
|
| Section | Key | Default | Description |
|
||||||
|---------|-----|---------|-------------|
|
|---------|-----|---------|-------------|
|
||||||
| OpcUa | Port | 4840 | OPC UA server port |
|
| `OpcUa` | `Port` | `4840` | OPC UA server port |
|
||||||
| OpcUa | EndpointPath | /LmxOpcUa | Endpoint path |
|
| `OpcUa` | `EndpointPath` | `/LmxOpcUa` | Endpoint path |
|
||||||
| OpcUa | GalaxyName | ZB | Galaxy name (used in namespace URI) |
|
| `OpcUa` | `GalaxyName` | `ZB` | Galaxy name (namespace URI) |
|
||||||
| OpcUa | MaxSessions | 100 | Maximum concurrent sessions |
|
| `OpcUa` | `MaxSessions` | `100` | Maximum concurrent sessions |
|
||||||
| MxAccess | ClientName | LmxOpcUa | MXAccess registration name |
|
| `OpcUa` | `AlarmTrackingEnabled` | `false` | Enable alarm condition tracking |
|
||||||
| MxAccess | AutoReconnect | true | Auto-reconnect on disconnect |
|
| `MxAccess` | `ClientName` | `LmxOpcUa` | MXAccess registration name |
|
||||||
| MxAccess | ProbeTag | null | Tag for connection health probing |
|
| `MxAccess` | `AutoReconnect` | `true` | Auto-reconnect on disconnect |
|
||||||
| GalaxyRepository | ConnectionString | Server=localhost;Database=ZB;... | ZB database connection |
|
| `MxAccess` | `ProbeTag` | `null` | Tag for connection health probing |
|
||||||
| GalaxyRepository | ChangeDetectionIntervalSeconds | 30 | Deploy change polling interval |
|
| `GalaxyRepository` | `ConnectionString` | `Server=localhost;Database=ZB;...` | ZB database connection |
|
||||||
| Dashboard | Enabled | true | Enable HTTP status dashboard |
|
| `GalaxyRepository` | `ChangeDetectionIntervalSeconds` | `30` | Deploy change polling interval |
|
||||||
| Dashboard | Port | 8081 | Dashboard port |
|
| `GalaxyRepository` | `ExtendedAttributes` | `false` | Include primitive/system attributes |
|
||||||
|
| `Historian` | `Enabled` | `false` | Enable OPC UA historical data access |
|
||||||
## Project Structure
|
| `Historian` | `ConnectionString` | `Server=localhost;Database=Runtime;...` | Historian Runtime database connection |
|
||||||
|
| `Historian` | `MaxValuesPerRead` | `10000` | Maximum values per HistoryRead request |
|
||||||
```
|
| `Dashboard` | `Enabled` | `true` | Enable HTTP status dashboard |
|
||||||
src/ZB.MOM.WW.LmxOpcUa.Host/
|
| `Dashboard` | `Port` | `8081` | Dashboard port |
|
||||||
Configuration/ Config binding and validation
|
|
||||||
Domain/ Interfaces, DTOs, enums, mappers
|
|
||||||
Metrics/ Performance tracking (rolling P95)
|
|
||||||
MxAccess/ STA thread, COM interop, subscriptions
|
|
||||||
GalaxyRepository/ SQL queries, change detection
|
|
||||||
OpcUa/ Server, node manager, address space
|
|
||||||
Status/ HTTP dashboard, health checks
|
|
||||||
OpcUaService.cs Service wiring (startup/shutdown)
|
|
||||||
Program.cs TopShelf entry point
|
|
||||||
|
|
||||||
tests/ZB.MOM.WW.LmxOpcUa.Tests/
|
|
||||||
Configuration/ Config binding tests
|
|
||||||
Domain/ Type mapping, quality, error code tests
|
|
||||||
Metrics/ Performance metrics tests
|
|
||||||
MxAccess/ STA thread, connection, subscription, R/W tests
|
|
||||||
GalaxyRepository/ Change detection tests
|
|
||||||
OpcUa/ Address space build/rebuild, data conversion tests
|
|
||||||
Status/ Health check, dashboard, web server tests
|
|
||||||
Wiring/ Component integration tests
|
|
||||||
EndToEnd/ Full data flow smoke test
|
|
||||||
|
|
||||||
tools/opcuacli-dotnet/ OPC UA CLI test tool
|
|
||||||
gr/ Galaxy repository docs, SQL queries, schema
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Type Mapping
|
## Data Type Mapping
|
||||||
|
|
||||||
@@ -148,6 +169,35 @@ gr/ Galaxy repository docs, SQL queries, schema
|
|||||||
|
|
||||||
Array attributes use `ValueRank=1` with `ArrayDimensions` from the Galaxy attribute definition.
|
Array attributes use `ValueRank=1` with `ArrayDimensions` from the Galaxy attribute definition.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/ZB.MOM.WW.LmxOpcUa.Host/
|
||||||
|
Configuration/ Config binding and validation
|
||||||
|
Domain/ Interfaces, DTOs, enums, mappers
|
||||||
|
Historian/ Wonderware Historian data source
|
||||||
|
Metrics/ Performance tracking (rolling P95)
|
||||||
|
MxAccess/ STA thread, COM interop, subscriptions
|
||||||
|
GalaxyRepository/ SQL queries, change detection
|
||||||
|
OpcUa/ Server, node manager, address space, alarm conditions, diff
|
||||||
|
Status/ HTTP dashboard, health checks
|
||||||
|
OpcUaService.cs Service wiring (startup/shutdown)
|
||||||
|
Program.cs TopShelf entry point
|
||||||
|
|
||||||
|
tests/ZB.MOM.WW.LmxOpcUa.Tests/
|
||||||
|
Domain/ Type mapping, security classification, quality tests
|
||||||
|
Historian/ Historian quality mapping tests
|
||||||
|
Integration/ Access levels, historizing, alarm events, incremental sync
|
||||||
|
Metrics/ Performance metrics tests
|
||||||
|
MxAccess/ STA thread, connection, subscription, R/W tests
|
||||||
|
GalaxyRepository/ Change detection tests
|
||||||
|
OpcUa/ Address space build/rebuild, diff, data conversion tests
|
||||||
|
Wiring/ Component integration tests
|
||||||
|
|
||||||
|
tools/opcuacli-dotnet/ OPC UA CLI test tool (connect, browse, read, write, subscribe, historyread, alarms)
|
||||||
|
gr/ Galaxy repository docs, SQL queries, schema
|
||||||
|
```
|
||||||
|
|
||||||
## Startup Sequence
|
## Startup Sequence
|
||||||
|
|
||||||
1. Load and validate configuration
|
1. Load and validate configuration
|
||||||
@@ -158,8 +208,37 @@ Array attributes use `ValueRank=1` with `ArrayDimensions` from the Galaxy attrib
|
|||||||
6. Test Galaxy database connection
|
6. Test Galaxy database connection
|
||||||
7. Start OPC UA server with programmatic config
|
7. Start OPC UA server with programmatic config
|
||||||
8. Query hierarchy + attributes, build address space
|
8. Query hierarchy + attributes, build address space
|
||||||
9. Start change detection polling (rebuilds on deploy)
|
9. Create alarm condition nodes and auto-subscribe to InAlarm tags (if enabled)
|
||||||
10. Start status dashboard (HTML + JSON + health endpoint)
|
10. Start change detection polling (incremental sync on deploy)
|
||||||
|
11. Start status dashboard (HTML + JSON + health endpoint)
|
||||||
|
|
||||||
|
## Component Documentation
|
||||||
|
|
||||||
|
| Component | Description |
|
||||||
|
|---|---|
|
||||||
|
| [OPC UA Server](docs/OpcUaServer.md) | Endpoint, sessions, security policy, server lifecycle |
|
||||||
|
| [Address Space](docs/AddressSpace.md) | Hierarchy nodes, variable nodes, primitive grouping, NodeId scheme |
|
||||||
|
| [Galaxy Repository](docs/GalaxyRepository.md) | SQL queries, deployed package chain, change detection |
|
||||||
|
| [MXAccess Bridge](docs/MxAccessBridge.md) | STA thread, COM interop, subscriptions, reconnection |
|
||||||
|
| [Data Type Mapping](docs/DataTypeMapping.md) | Galaxy → OPC UA types, arrays, security classification |
|
||||||
|
| [Read/Write Operations](docs/ReadWriteOperations.md) | Value reads, writes, access level enforcement, array element writes |
|
||||||
|
| [Subscriptions](docs/Subscriptions.md) | Ref-counted MXAccess subscriptions, data change dispatch |
|
||||||
|
| [Alarm Tracking](docs/AlarmTracking.md) | AlarmConditionState nodes, InAlarm monitoring, event reporting |
|
||||||
|
| [Historical Data Access](docs/HistoricalDataAccess.md) | Historian data source, HistoryReadRaw, HistoryReadProcessed |
|
||||||
|
| [Incremental Sync](docs/IncrementalSync.md) | Diff computation, subtree teardown/rebuild, subscription preservation |
|
||||||
|
| [Configuration](docs/Configuration.md) | appsettings.json binding, feature flags, validation |
|
||||||
|
| [Status Dashboard](docs/StatusDashboard.md) | HTTP server, health checks, metrics reporting |
|
||||||
|
| [Service Hosting](docs/ServiceHosting.md) | TopShelf, startup/shutdown sequence, error handling |
|
||||||
|
| [CLI Tool](docs/CliTool.md) | Connect, browse, read, write, subscribe, historyread, alarms commands |
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Galaxy Repository Queries](gr/CLAUDE.md) — SQL queries for hierarchy, attributes, and change detection
|
||||||
|
- [Data Type Mapping](gr/data_type_mapping.md) — Galaxy to OPC UA type mapping with security classification
|
||||||
|
- [Alarm Plan](hda_plan.md) — Alarm detection and OPC UA alarm condition design
|
||||||
|
- [Historian Plan](historian_plan.md) — Historical data access via Wonderware Historian
|
||||||
|
- [Incremental Sync Plan](partial_update.md) — Subtree-level address space sync design
|
||||||
|
- [Galaxy Security](gr_security.md) — Users, roles, and permissions in the Galaxy repository
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
76
docs/AddressSpace.md
Normal file
76
docs/AddressSpace.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Root ZB 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.
|
||||||
|
|
||||||
|
The root folder has `EventNotifier = SubscribeToEvents` enabled so alarm events propagate up to clients subscribed at the root level.
|
||||||
|
|
||||||
|
## Area Folders vs Object Nodes
|
||||||
|
|
||||||
|
Galaxy objects fall into two categories based on `template_definition.category_id`:
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
Both node types use `contained_name` as the browse name. When `contained_name` is null or empty, `tag_name` is used as a fallback.
|
||||||
|
|
||||||
|
## Variable Nodes for Attributes
|
||||||
|
|
||||||
|
Each Galaxy attribute becomes a `BaseDataVariableState` node under its parent object. The variable is configured with:
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
## Primitive Grouping
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
### First pass: direct attributes
|
||||||
|
|
||||||
|
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`):
|
||||||
|
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
## Topological Sort
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Key source files
|
||||||
|
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs` -- Node manager with `BuildAddressSpace`, `SyncAddressSpace`, and `TopologicalSort`
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/AddressSpaceBuilder.cs` -- Testable in-memory model builder
|
||||||
152
docs/AlarmTracking.md
Normal file
152
docs/AlarmTracking.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## AlarmInfo Structure
|
||||||
|
|
||||||
|
Each tracked alarm is represented by an `AlarmInfo` instance stored in the `_alarmInAlarmTags` dictionary, keyed by the `InAlarm` tag reference:
|
||||||
|
|
||||||
|
```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; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`LastInAlarm` enables edge detection so only actual transitions (inactive-to-active or active-to-inactive) generate events, not repeated identical values.
|
||||||
|
|
||||||
|
## Alarm Detection via is_alarm Flag
|
||||||
|
|
||||||
|
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):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var alarmAttrs = objAttrs.Where(a => a.IsAlarm && string.IsNullOrEmpty(a.PrimitiveName)).ToList();
|
||||||
|
```
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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 on Parent Nodes
|
||||||
|
|
||||||
|
When a Galaxy object contains at least one alarm attribute, its OPC UA node is updated to include `EventNotifiers.SubscribeToEvents`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (hasAlarms && _nodeMap.TryGetValue(obj.GobjectId, out var objNode))
|
||||||
|
{
|
||||||
|
if (objNode is BaseObjectState objState)
|
||||||
|
objState.EventNotifier = EventNotifiers.SubscribeToEvents;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows OPC UA clients to subscribe to events on the parent object node and receive alarm notifications for all child attributes. 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.
|
||||||
|
|
||||||
|
The event is reported through two paths:
|
||||||
|
1. **Parent node** -- `sourceVar.Parent.ReportEvent` propagates the event to clients subscribed on the parent Galaxy object.
|
||||||
|
2. **Server node** -- `Server.ReportEvent` ensures clients subscribed at the server level also receive the event.
|
||||||
|
|
||||||
|
## 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.
|
||||||
182
docs/CliTool.md
Normal file
182
docs/CliTool.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# CLI Tool
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The OPC UA CLI tool at `tools/opcuacli-dotnet/` is a command-line utility for testing OPC UA server functions. It targets .NET 10 and uses the OPC Foundation UA .NET Standard client library for OPC UA operations and [CliFx](https://github.com/Tyrrrz/CliFx) for command routing and argument parsing.
|
||||||
|
|
||||||
|
The tool is not part of the production service. It exists to verify that the LmxOpcUa server correctly exposes browse nodes, reads, writes, subscriptions, historical data, and alarm events without requiring a full OPC UA client application.
|
||||||
|
|
||||||
|
## Build and Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/opcuacli-dotnet
|
||||||
|
dotnet build
|
||||||
|
dotnet run -- <command> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shared Session Creation
|
||||||
|
|
||||||
|
`OpcUaHelper.ConnectAsync()` creates an OPC UA client session used by all commands. It configures the application identity, sets up directory-based certificate stores under `%LocalAppData%\OpcUaCli\pki\`, and auto-accepts untrusted server certificates. The session timeout is 60 seconds.
|
||||||
|
|
||||||
|
`OpcUaHelper.ConvertValue()` converts a raw string from the command line into the runtime type expected by the target node. It uses the current node value to infer the type (bool, byte, short, int, float, double, etc.) and falls back to string if the type is not recognized.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### connect
|
||||||
|
|
||||||
|
Tests connectivity to an OPC UA server endpoint. Creates a session and immediately disconnects.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run -- connect -u opc.tcp://localhost:4840
|
||||||
|
```
|
||||||
|
|
||||||
|
### browse
|
||||||
|
|
||||||
|
Browses the OPC UA address space starting from the Objects folder or a specified node. Supports recursive traversal with a configurable depth limit.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Browse top-level Objects folder
|
||||||
|
dotnet run -- browse -u opc.tcp://localhost:4840
|
||||||
|
|
||||||
|
# Browse a specific node
|
||||||
|
dotnet run -- browse -u opc.tcp://localhost:4840 -n "ns=2;s=MyFolder"
|
||||||
|
|
||||||
|
# Browse recursively to depth 3
|
||||||
|
dotnet run -- browse -u opc.tcp://localhost:4840 -r -d 3
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `-u` | OPC UA server endpoint URL (required) |
|
||||||
|
| `-n` | Node ID to browse (default: Objects folder) |
|
||||||
|
| `-d` | Maximum browse depth (default: 1) |
|
||||||
|
| `-r` | Browse recursively using `-d` as max depth |
|
||||||
|
|
||||||
|
### read
|
||||||
|
|
||||||
|
Reads the current value of a single node and prints the value, data type, status code, and timestamps.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run -- read -u opc.tcp://localhost:4840 -n "ns=2;s=TestMachine_001.SomeAttribute"
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `-u` | OPC UA server endpoint URL (required) |
|
||||||
|
| `-n` | Node ID to read (required) |
|
||||||
|
|
||||||
|
### write
|
||||||
|
|
||||||
|
Writes a value to a node. The command reads the current value first to determine the target data type, then converts the supplied string value to that type using `OpcUaHelper.ConvertValue()`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run -- write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `-u` | OPC UA server endpoint URL (required) |
|
||||||
|
| `-n` | Node ID to write to (required) |
|
||||||
|
| `-v` | Value to write (required) |
|
||||||
|
|
||||||
|
### subscribe
|
||||||
|
|
||||||
|
Monitors a node for value changes using OPC UA subscriptions. Creates a `MonitoredItem` with the specified sampling interval and prints each notification with its source timestamp, value, and status code. Prints periodic tick lines showing session state, subscription ID, publishing status, and last notification value. Runs until Ctrl+C.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run -- subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -i 500
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `-u` | OPC UA server endpoint URL (required) |
|
||||||
|
| `-n` | Node ID to monitor (required) |
|
||||||
|
| `-i` | Sampling/publishing interval in milliseconds (default: 1000) |
|
||||||
|
|
||||||
|
### historyread
|
||||||
|
|
||||||
|
Reads historical data from a node. Supports both raw history reads and aggregate (processed) history reads.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Raw history
|
||||||
|
dotnet run -- historyread -u opc.tcp://localhost:4840/LmxOpcUa \
|
||||||
|
-n "ns=1;s=TestMachine_001.TestHistoryValue" \
|
||||||
|
--start "2026-03-25" --end "2026-03-30"
|
||||||
|
|
||||||
|
# Aggregate: 1-hour average
|
||||||
|
dotnet run -- historyread -u opc.tcp://localhost:4840/LmxOpcUa \
|
||||||
|
-n "ns=1;s=TestMachine_001.TestHistoryValue" \
|
||||||
|
--start "2026-03-25" --end "2026-03-30" \
|
||||||
|
--aggregate Average --interval 3600000
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `-u` | OPC UA server endpoint URL (required) |
|
||||||
|
| `-n` | Node ID to read history for (required) |
|
||||||
|
| `--start` | Start time, ISO 8601 or date string (default: 24 hours ago) |
|
||||||
|
| `--end` | End time, ISO 8601 or date string (default: now) |
|
||||||
|
| `--max` | Maximum number of values (default: 1000) |
|
||||||
|
| `--aggregate` | Aggregate function: Average, Minimum, Maximum, Count, Start, End |
|
||||||
|
| `--interval` | Processing interval in milliseconds for aggregates (default: 3600000) |
|
||||||
|
|
||||||
|
#### Continuation points
|
||||||
|
|
||||||
|
When reading raw history, the server may return more values than fit in a single response. The command handles this by checking the `ContinuationPoint` on each result. If a continuation point is present, it issues follow-up `HistoryRead` calls with `ReleaseContinuationPoints = false` (i.e., `continuationPoint != null` is passed to the next read) until either the continuation point is empty or the `--max` limit is reached. This ensures that large result sets are fetched in full without requiring the caller to manage pagination.
|
||||||
|
|
||||||
|
#### Aggregate mapping
|
||||||
|
|
||||||
|
The `--aggregate` option maps human-readable names to OPC UA aggregate function node IDs:
|
||||||
|
|
||||||
|
| Name | OPC UA Node ID |
|
||||||
|
|------|---------------|
|
||||||
|
| `average` | `AggregateFunction_Average` |
|
||||||
|
| `minimum` / `min` | `AggregateFunction_Minimum` |
|
||||||
|
| `maximum` / `max` | `AggregateFunction_Maximum` |
|
||||||
|
| `count` | `AggregateFunction_Count` |
|
||||||
|
| `start` / `first` | `AggregateFunction_Start` |
|
||||||
|
| `end` / `last` | `AggregateFunction_End` |
|
||||||
|
|
||||||
|
### alarms
|
||||||
|
|
||||||
|
Subscribes to alarm events on a node using OPC UA event subscriptions. Creates a `MonitoredItem` with `AttributeId = EventNotifier` and an `EventFilter` that selects alarm-relevant fields from the event stream. Runs until Ctrl+C.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Subscribe to alarm events
|
||||||
|
dotnet run -- alarms -u opc.tcp://localhost:4840/LmxOpcUa \
|
||||||
|
-n "ns=1;s=TestMachine_001"
|
||||||
|
|
||||||
|
# With condition refresh to get current alarm states
|
||||||
|
dotnet run -- alarms -u opc.tcp://localhost:4840/LmxOpcUa \
|
||||||
|
-n "ns=1;s=TestMachine_001" --refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `-u` | OPC UA server endpoint URL (required) |
|
||||||
|
| `-n` | Node ID to monitor for events (default: Server node) |
|
||||||
|
| `-i` | Publishing interval in milliseconds (default: 1000) |
|
||||||
|
| `--refresh` | Request a `ConditionRefresh` after subscribing to get current retained alarm states |
|
||||||
|
|
||||||
|
#### EventFilter and alarm display
|
||||||
|
|
||||||
|
The command builds an `EventFilter` with select clauses for 12 fields from the OPC UA alarm type hierarchy:
|
||||||
|
|
||||||
|
| Index | Type | Field |
|
||||||
|
|-------|------|-------|
|
||||||
|
| 0 | `BaseEventType` | `EventId` |
|
||||||
|
| 1 | `BaseEventType` | `EventType` |
|
||||||
|
| 2 | `BaseEventType` | `SourceName` |
|
||||||
|
| 3 | `BaseEventType` | `Time` |
|
||||||
|
| 4 | `BaseEventType` | `Message` |
|
||||||
|
| 5 | `BaseEventType` | `Severity` |
|
||||||
|
| 6 | `ConditionType` | `ConditionName` |
|
||||||
|
| 7 | `ConditionType` | `Retain` |
|
||||||
|
| 8 | `AcknowledgeableConditionType` | `AckedState/Id` |
|
||||||
|
| 9 | `AlarmConditionType` | `ActiveState/Id` |
|
||||||
|
| 10 | `AlarmConditionType` | `EnabledState/Id` |
|
||||||
|
| 11 | `AlarmConditionType` | `SuppressedOrShelved` |
|
||||||
|
|
||||||
|
When an `EventFieldList` notification arrives, the handler extracts these fields by index and prints a structured alarm event to the console showing the source name, condition name, active/acknowledged state, severity, message, retain flag, and suppressed/shelved status.
|
||||||
|
|
||||||
|
The `--refresh` flag calls `subscription.ConditionRefreshAsync()` after the subscription is created, which asks the server to re-emit retained condition events so the operator sees the current alarm state immediately rather than waiting for the next transition.
|
||||||
182
docs/Configuration.md
Normal file
182
docs/Configuration.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# Configuration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The service loads configuration from `appsettings.json` at startup using the Microsoft.Extensions.Configuration stack. `AppConfiguration` is the root holder class that aggregates five typed sections: `OpcUa`, `MxAccess`, `GalaxyRepository`, `Dashboard`, and `Historian`. Each section binds to a dedicated POCO class with sensible defaults, so the service runs with zero configuration on a standard deployment.
|
||||||
|
|
||||||
|
## Config Binding Pattern
|
||||||
|
|
||||||
|
The production constructor in `OpcUaService` builds the configuration pipeline and binds each JSON section to its typed class:
|
||||||
|
|
||||||
|
```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);
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Environment-Specific Overrides
|
||||||
|
|
||||||
|
The configuration pipeline supports three layers of override, applied in order:
|
||||||
|
|
||||||
|
1. `appsettings.json` -- base configuration (required)
|
||||||
|
2. `appsettings.{DOTNET_ENVIRONMENT}.json` -- environment-specific overlay (optional)
|
||||||
|
3. Environment variables -- highest priority, useful for deployment automation
|
||||||
|
|
||||||
|
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 |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
|
||||||
|
### 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 connection for OPC UA historical data access. Defined in `HistorianConfiguration`.
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `Enabled` | `bool` | `false` | Enables OPC UA historical data access |
|
||||||
|
| `ConnectionString` | `string` | `"Server=localhost;Database=Runtime;Integrated Security=true;"` | Connection string for the Historian Runtime database |
|
||||||
|
| `CommandTimeoutSeconds` | `int` | `30` | SQL command timeout for historian queries |
|
||||||
|
| `MaxValuesPerRead` | `int` | `10000` | Maximum values returned per `HistoryRead` request |
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **`Historian.Enabled`** -- When `true`, the service creates a `HistorianDataSource` connected to the Wonderware Historian Runtime database and registers it with the OPC UA server host. Disabled by default because not all deployments have a Historian instance.
|
||||||
|
- **`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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"OpcUa": {
|
||||||
|
"Port": 4840,
|
||||||
|
"EndpointPath": "/LmxOpcUa",
|
||||||
|
"ServerName": "LmxOpcUa",
|
||||||
|
"GalaxyName": "ZB",
|
||||||
|
"MaxSessions": 100,
|
||||||
|
"SessionTimeoutMinutes": 30,
|
||||||
|
"AlarmTrackingEnabled": false
|
||||||
|
},
|
||||||
|
"MxAccess": {
|
||||||
|
"ClientName": "LmxOpcUa",
|
||||||
|
"NodeName": null,
|
||||||
|
"GalaxyName": null,
|
||||||
|
"ReadTimeoutSeconds": 5,
|
||||||
|
"WriteTimeoutSeconds": 5,
|
||||||
|
"MaxConcurrentOperations": 10,
|
||||||
|
"MonitorIntervalSeconds": 5,
|
||||||
|
"AutoReconnect": true,
|
||||||
|
"ProbeTag": null,
|
||||||
|
"ProbeStaleThresholdSeconds": 60
|
||||||
|
},
|
||||||
|
"GalaxyRepository": {
|
||||||
|
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=true;",
|
||||||
|
"ChangeDetectionIntervalSeconds": 30,
|
||||||
|
"CommandTimeoutSeconds": 30,
|
||||||
|
"ExtendedAttributes": false
|
||||||
|
},
|
||||||
|
"Dashboard": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Port": 8081,
|
||||||
|
"RefreshIntervalSeconds": 10
|
||||||
|
},
|
||||||
|
"Historian": {
|
||||||
|
"Enabled": false,
|
||||||
|
"ConnectionString": "Server=localhost;Database=Runtime;Integrated Security=true;",
|
||||||
|
"CommandTimeoutSeconds": 30,
|
||||||
|
"MaxValuesPerRead": 10000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
84
docs/DataTypeMapping.md
Normal file
84
docs/DataTypeMapping.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## mx_data_type to OPC UA Type Mapping
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Why ElapsedTime maps to Double
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Array Handling
|
||||||
|
|
||||||
|
Galaxy attributes with `is_array = 1` in the repository are exposed as one-dimensional OPC UA array variables.
|
||||||
|
|
||||||
|
### ValueRank
|
||||||
|
|
||||||
|
The `ValueRank` property on the OPC UA variable node indicates the array dimensionality:
|
||||||
|
|
||||||
|
| `is_array` | ValueRank | Constant |
|
||||||
|
|:---:|:---------:|----------|
|
||||||
|
| 0 | -1 | `ValueRanks.Scalar` |
|
||||||
|
| 1 | 1 | `ValueRanks.OneDimension` |
|
||||||
|
|
||||||
|
### ArrayDimensions
|
||||||
|
|
||||||
|
When `ValueRank = 1`, the `ArrayDimensions` property is set to a single-element `ReadOnlyList<uint>` containing the declared array length from `array_dimension`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (attr.IsArray && attr.ArrayDimension.HasValue)
|
||||||
|
{
|
||||||
|
variable.ArrayDimensions = new ReadOnlyList<uint>(
|
||||||
|
new List<uint> { (uint)attr.ArrayDimension.Value });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `array_dimension` value is extracted from the `mx_value` binary column in the Galaxy database (bytes 13-16, little-endian int32).
|
||||||
|
|
||||||
|
### NodeId for array variables
|
||||||
|
|
||||||
|
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 a `HistorianDataSource` is configured.
|
||||||
|
|
||||||
|
## Key source files
|
||||||
|
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/Domain/MxDataTypeMapper.cs` -- Type and CLR mapping
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/Domain/SecurityClassificationMapper.cs` -- Write access mapping
|
||||||
|
- `gr/data_type_mapping.md` -- Reference documentation for the full mapping table
|
||||||
88
docs/GalaxyRepository.md
Normal file
88
docs/GalaxyRepository.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Galaxy Repository
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
## Connection Configuration
|
||||||
|
|
||||||
|
`GalaxyRepositoryConfiguration` controls database access:
|
||||||
|
|
||||||
|
| Property | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `ConnectionString` | `Server=localhost;Database=ZB;Integrated Security=true;` | SQL Server connection using Windows Authentication |
|
||||||
|
| `ChangeDetectionIntervalSeconds` | `30` | Polling frequency for deploy change detection |
|
||||||
|
| `CommandTimeoutSeconds` | `30` | SQL command timeout for all queries |
|
||||||
|
| `ExtendedAttributes` | `false` | When true, loads primitive-level attributes in addition to dynamic attributes |
|
||||||
|
|
||||||
|
The connection uses Windows Authentication because the Galaxy Repository database is local to the System Platform node and secured through domain credentials.
|
||||||
|
|
||||||
|
## SQL Queries
|
||||||
|
|
||||||
|
All queries are embedded as `const string` fields in `GalaxyRepositoryService`. No dynamic SQL is used.
|
||||||
|
|
||||||
|
### Hierarchy query
|
||||||
|
|
||||||
|
Returns deployed Galaxy objects with their parent relationships and browse names:
|
||||||
|
|
||||||
|
- Joins `gobject` to `template_definition` to filter by relevant `category_id` values (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||||
|
- Uses `contained_name` as the browse name, falling back to `tag_name` when `contained_name` is null or empty
|
||||||
|
- Resolves the parent using `contained_by_gobject_id` when non-zero, otherwise falls back to `area_gobject_id`
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
### Attributes query (standard)
|
||||||
|
|
||||||
|
Returns user-defined dynamic attributes for deployed objects:
|
||||||
|
|
||||||
|
- Uses a recursive CTE (`deployed_package_chain`) to walk the package inheritance chain from `deployed_package_id` through `derived_from_package_id`, limited to 10 levels
|
||||||
|
- Joins `dynamic_attribute` on each package in the chain to collect inherited attributes
|
||||||
|
- Uses `ROW_NUMBER() OVER (PARTITION BY gobject_id, attribute_name ORDER BY depth)` to pick the most-derived definition when an attribute is overridden at multiple levels
|
||||||
|
- Builds `full_tag_reference` as `tag_name.attribute_name` with `[]` appended for arrays
|
||||||
|
- Extracts `array_dimension` from the binary `mx_value` column (bytes 13-16, little-endian int32)
|
||||||
|
- Detects historized attributes by checking for a `HistoryExtension` primitive instance
|
||||||
|
- Detects alarm attributes by checking for an `AlarmExtension` primitive instance
|
||||||
|
- Excludes internal attributes (names starting with `_`) and `.Description` suffixes
|
||||||
|
- Filters by `mx_attribute_category` to include only user-relevant categories
|
||||||
|
|
||||||
|
### Attributes query (extended)
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
The `full_tag_reference` for primitive attributes follows the pattern `tag_name.primitive_name.attribute_name` (e.g., `TestMachine_001.AlarmAttr.InAlarm`).
|
||||||
|
|
||||||
|
### Change detection query
|
||||||
|
|
||||||
|
A single-column query: `SELECT time_of_last_deploy FROM galaxy`. The `galaxy` table contains one row with the timestamp of the most recent deployment.
|
||||||
|
|
||||||
|
## Why deployed_package_id Instead of checked_in_package_id
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Change Detection Polling
|
||||||
|
|
||||||
|
`ChangeDetectionService` runs a background polling loop 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`).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Key source files
|
||||||
|
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/GalaxyRepositoryService.cs` -- SQL queries and data access
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/GalaxyRepository/ChangeDetectionService.cs` -- Deploy timestamp polling loop
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/GalaxyRepositoryConfiguration.cs` -- Connection and polling settings
|
||||||
141
docs/HistoricalDataAccess.md
Normal file
141
docs/HistoricalDataAccess.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Historical Data Access
|
||||||
|
|
||||||
|
`LmxNodeManager` exposes OPC UA historical data access (HDA) by querying the Wonderware Historian Runtime database. The `HistorianDataSource` class translates OPC UA history requests into SQL queries against the Historian's `History` and `AnalogSummaryHistory` views, and the node manager overrides wire the results back into the OPC UA response.
|
||||||
|
|
||||||
|
## Wonderware Historian Runtime Database
|
||||||
|
|
||||||
|
The Historian stores time-series data in a SQL Server database named `Runtime`. Two views are relevant:
|
||||||
|
|
||||||
|
- **`Runtime.dbo.History`** -- Raw historical samples with columns `DateTime`, `Value` (numeric), `vValue` (string), and `Quality`.
|
||||||
|
- **`Runtime.dbo.AnalogSummaryHistory`** -- Pre-computed aggregates bucketed by `wwResolution` (milliseconds), with columns like `Average`, `Minimum`, `Maximum`, `ValueCount`, `First`, `Last`, `StdDev`.
|
||||||
|
|
||||||
|
Both views require `TagName` in the `WHERE` clause. This is a Historian constraint -- the views are optimized for tag-scoped queries and do not support efficient cross-tag scans.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`HistorianConfiguration` controls the historian connection:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class HistorianConfiguration
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; } = false;
|
||||||
|
public string ConnectionString { get; set; } =
|
||||||
|
"Server=localhost;Database=Runtime;Integrated Security=true;";
|
||||||
|
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||||
|
public int MaxValuesPerRead { get; set; } = 10000;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `Enabled` is `false`, the `HistorianDataSource` is not instantiated and the node manager returns `BadHistoryOperationUnsupported` for history read requests.
|
||||||
|
|
||||||
|
## Raw Reads
|
||||||
|
|
||||||
|
`HistorianDataSource.ReadRawAsync` queries the `History` view for individual samples within a time range:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT TOP (@MaxValues) DateTime, Value, vValue, Quality
|
||||||
|
FROM Runtime.dbo.History
|
||||||
|
WHERE TagName = @TagName
|
||||||
|
AND DateTime >= @StartTime AND DateTime <= @EndTime
|
||||||
|
ORDER BY DateTime
|
||||||
|
```
|
||||||
|
|
||||||
|
The `TOP` clause is included only when `maxValues > 0` (the OPC UA client specified `NumValuesPerNode`). Each row is converted to an OPC UA `DataValue`:
|
||||||
|
|
||||||
|
- `Value` column (double) takes priority over `vValue` (string). If both are null, the value is null.
|
||||||
|
- `SourceTimestamp` and `ServerTimestamp` are both set to the `DateTime` column.
|
||||||
|
- `StatusCode` is mapped from the Historian `Quality` byte via `MapQuality`.
|
||||||
|
|
||||||
|
## Aggregate Reads
|
||||||
|
|
||||||
|
`HistorianDataSource.ReadAggregateAsync` queries the `AnalogSummaryHistory` view for pre-computed aggregates:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT StartDateTime, [{aggregateColumn}]
|
||||||
|
FROM Runtime.dbo.AnalogSummaryHistory
|
||||||
|
WHERE TagName = @TagName
|
||||||
|
AND StartDateTime >= @StartTime AND StartDateTime <= @EndTime
|
||||||
|
AND wwResolution = @Resolution
|
||||||
|
ORDER BY StartDateTime
|
||||||
|
```
|
||||||
|
|
||||||
|
The `aggregateColumn` is interpolated directly into the SQL (it comes from the controlled `MapAggregateToColumn` mapping, not from user input). The `wwResolution` parameter maps from the OPC UA `ProcessingInterval` in milliseconds.
|
||||||
|
|
||||||
|
Null aggregate values return `BadNoData` status rather than `Good` with a null variant.
|
||||||
|
|
||||||
|
## Quality Mapping
|
||||||
|
|
||||||
|
`MapQuality` converts Wonderware Historian quality bytes to OPC UA status codes:
|
||||||
|
|
||||||
|
| Historian Quality | OPC UA StatusCode |
|
||||||
|
|---|---|
|
||||||
|
| 0 | `Good` |
|
||||||
|
| 1 | `Bad` |
|
||||||
|
| 2-127 | `Bad` |
|
||||||
|
| 128+ | `Uncertain` |
|
||||||
|
|
||||||
|
This follows the Wonderware convention where quality 0 indicates a good sample, 1 indicates explicitly bad data, and values at or above 128 represent uncertain quality (e.g., interpolated or suspect values).
|
||||||
|
|
||||||
|
## Aggregate Function Mapping
|
||||||
|
|
||||||
|
`MapAggregateToColumn` translates OPC UA aggregate NodeIds to Historian column names:
|
||||||
|
|
||||||
|
| OPC UA Aggregate | Historian Column |
|
||||||
|
|---|---|
|
||||||
|
| `AggregateFunction_Average` | `Average` |
|
||||||
|
| `AggregateFunction_Minimum` | `Minimum` |
|
||||||
|
| `AggregateFunction_Maximum` | `Maximum` |
|
||||||
|
| `AggregateFunction_Count` | `ValueCount` |
|
||||||
|
| `AggregateFunction_Start` | `First` |
|
||||||
|
| `AggregateFunction_End` | `Last` |
|
||||||
|
| `AggregateFunction_StandardDeviationPopulation` | `StdDev` |
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var dataValues = _historianDataSource.ReadRawAsync(
|
||||||
|
tagRef, details.StartTime, details.EndTime, maxValues)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
var historyData = new HistoryData();
|
||||||
|
historyData.DataValues.AddRange(dataValues);
|
||||||
|
results[idx] = new HistoryReadResult
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Good,
|
||||||
|
HistoryData = new ExtensionObject(historyData)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 Historian column via `MapAggregateToColumn`. Return `BadAggregateNotSupported` if unmapped.
|
||||||
|
4. Call `ReadAggregateAsync` with the time range, `ProcessingInterval`, and column 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:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (attr.IsHistorized)
|
||||||
|
accessLevel |= AccessLevels.HistoryRead;
|
||||||
|
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`.
|
||||||
|
|
||||||
|
The `IsHistorized` flag originates from the Galaxy repository database query, which checks whether the attribute has Historian logging configured.
|
||||||
121
docs/IncrementalSync.md
Normal file
121
docs/IncrementalSync.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Cached State
|
||||||
|
|
||||||
|
`LmxNodeManager` retains shallow copies of the last-published hierarchy and attributes:
|
||||||
|
|
||||||
|
```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)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## SyncAddressSpace Flow
|
||||||
|
|
||||||
|
`SyncAddressSpace` orchestrates the incremental update inside the OPC UA framework `Lock`:
|
||||||
|
|
||||||
|
1. **Diff** -- Call `FindChangedGobjectIds` with the cached and new snapshots. If no changes are detected, update the cached snapshots and return early.
|
||||||
|
|
||||||
|
2. **Expand** -- Call `ExpandToSubtrees` on both old and new hierarchies to include descendant objects.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
4. **Teardown** -- Call `TearDownGobjects` to remove the old nodes and clean up tracking state.
|
||||||
|
|
||||||
|
5. **Rebuild** -- Filter the new hierarchy and attributes to only the changed gobject IDs, then call `BuildSubtree` to create the replacement nodes.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
7. **Update cache** -- Replace `_lastHierarchy` and `_lastAttributes` with shallow copies of the new data.
|
||||||
|
|
||||||
|
## TearDownGobjects
|
||||||
|
|
||||||
|
`TearDownGobjects` removes all OPC UA nodes and tracking state for a set of gobject IDs:
|
||||||
|
|
||||||
|
For each gobject ID, it processes the associated tag references from `_gobjectToTagRefs`:
|
||||||
|
|
||||||
|
1. **Unsubscribe** -- If the tag has an active MXAccess subscription (entry in `_subscriptionRefCounts`), call `UnsubscribeAsync` and remove the ref-count entry.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
3. **Delete variable node** -- Call `DeleteNode` on the variable's `NodeId`, remove from `_tagToVariableNode`, clean up `_nodeIdToTagReference` and `_tagMetadata`, and decrement `VariableNodeCount`.
|
||||||
|
|
||||||
|
4. **Delete object/folder node** -- Remove the gobject's entry from `_nodeMap` and call `DeleteNode`. Non-folder nodes decrement `ObjectNodeCount`.
|
||||||
|
|
||||||
|
All MXAccess calls and `DeleteNode` calls are wrapped in try/catch with ignored exceptions, since teardown must complete even if individual cleanup steps fail.
|
||||||
|
|
||||||
|
## 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.
|
||||||
111
docs/MxAccessBridge.md
Normal file
111
docs/MxAccessBridge.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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.LmxOpcUa.Host/MxAccess/StaComThread.cs` -- STA thread and Win32 message pump
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.cs` -- Core client class (partial)
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.Connection.cs` -- Connect, disconnect, reconnect
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.Subscription.cs` -- Subscribe, unsubscribe, replay
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.ReadWrite.cs` -- Read and write operations
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.EventHandlers.cs` -- OnDataChange and OnWriteComplete handlers
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxAccessClient.Monitor.cs` -- Background health monitor
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/MxAccess/MxProxyAdapter.cs` -- COM object wrapper
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IMxAccessClient.cs` -- Client interface
|
||||||
90
docs/OpcUaServer.md
Normal file
90
docs/OpcUaServer.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`OpcUaConfiguration` defines the server endpoint and session settings. All properties have sensible defaults:
|
||||||
|
|
||||||
|
| Property | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
The resulting endpoint URL is `opc.tcp://0.0.0.0:{Port}{EndpointPath}`, e.g., `opc.tcp://0.0.0.0:4840/LmxOpcUa`.
|
||||||
|
|
||||||
|
The namespace URI follows the pattern `urn:{GalaxyName}:LmxOpcUa` and serves as both the `ApplicationUri` and `ProductUri`.
|
||||||
|
|
||||||
|
## Programmatic ApplicationConfiguration
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
The configuration covers:
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
## Security Policy
|
||||||
|
|
||||||
|
The server runs with `MessageSecurityMode.None` and `SecurityPolicies.None`. Only anonymous user tokens are accepted:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
SecurityPolicies = { new ServerSecurityPolicy
|
||||||
|
{
|
||||||
|
SecurityMode = MessageSecurityMode.None,
|
||||||
|
SecurityPolicyUri = SecurityPolicies.None
|
||||||
|
} },
|
||||||
|
UserTokenPolicies = { new UserTokenPolicy(UserTokenType.Anonymous) }
|
||||||
|
```
|
||||||
|
|
||||||
|
This is intentional for plant-floor deployments where the server sits on an isolated OT network. Galaxy-level security classification controls write access per attribute rather than at the transport layer.
|
||||||
|
|
||||||
|
## Certificate handling
|
||||||
|
|
||||||
|
On startup, `OpcUaServerHost.StartAsync` calls `CheckApplicationInstanceCertificate(false, 2048)` to locate or create a 2048-bit self-signed certificate. The certificate subject follows the format `CN={ServerName}, O=ZB MOM, DC=localhost`. Certificate stores use the directory-based store type under `%LOCALAPPDATA%\OPC Foundation\pki\`:
|
||||||
|
|
||||||
|
| Store | Path suffix |
|
||||||
|
|-------|-------------|
|
||||||
|
| Own | `pki/own` |
|
||||||
|
| Trusted issuers | `pki/issuer` |
|
||||||
|
| Trusted peers | `pki/trusted` |
|
||||||
|
| Rejected | `pki/rejected` |
|
||||||
|
|
||||||
|
`AutoAcceptUntrustedCertificates` is set to `true` so the server does not reject client certificates.
|
||||||
|
|
||||||
|
## 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 `HistorianDataSource`. The node manager is wrapped in a `MasterNodeManager` with no additional core node managers.
|
||||||
|
- **`LoadServerProperties`** -- Returns server metadata: manufacturer `ZB MOM`, product `LmxOpcUa Server`, and the assembly version as the software version.
|
||||||
|
|
||||||
|
### 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`.
|
||||||
|
|
||||||
|
## Key source files
|
||||||
|
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs` -- Application lifecycle and programmatic configuration
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs` -- StandardServer subclass and node manager creation
|
||||||
|
- `src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs` -- Configuration POCO
|
||||||
88
docs/ReadWriteOperations.md
Normal file
88
docs/ReadWriteOperations.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Read Override
|
||||||
|
|
||||||
|
The `Read` override in `LmxNodeManager` intercepts value attribute reads for nodes in the Galaxy namespace.
|
||||||
|
|
||||||
|
### Resolution flow
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
|
||||||
|
{
|
||||||
|
var vtq = _mxAccessClient.ReadAsync(tagRef).GetAwaiter().GetResult();
|
||||||
|
results[i] = CreatePublishedDataValue(tagRef, vtq);
|
||||||
|
errors[i] = ServiceResult.Good;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Write Override
|
||||||
|
|
||||||
|
The `Write` override follows a similar pattern but includes access-level enforcement and array element write support.
|
||||||
|
|
||||||
|
### Access level check
|
||||||
|
|
||||||
|
The base class `Write` runs first and sets `BadNotWritable` for nodes whose `AccessLevel` does not include `CurrentWrite`. The override skips these nodes:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (errors[i] != null && errors[i].StatusCode == StatusCodes.BadNotWritable)
|
||||||
|
continue;
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Write flow
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Array element writes via IndexRange
|
||||||
|
|
||||||
|
`TryApplyArrayElementWrite` supports writing individual elements of an array attribute. MXAccess does not support element-level writes, so the method performs a read-modify-write:
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var nextArray = (Array)currentArray.Clone();
|
||||||
|
nextArray.SetValue(ConvertArrayElementValue(normalizedValue, elementType), index);
|
||||||
|
updatedArray = nextArray;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
137
docs/ServiceHosting.md
Normal file
137
docs/ServiceHosting.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Service Hosting
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## TopShelf Configuration
|
||||||
|
|
||||||
|
`Program.Main()` configures TopShelf to manage the `OpcUaService` lifecycle:
|
||||||
|
|
||||||
|
```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();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
TopShelf provides these deployment modes from the same executable:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `LmxOpcUa.Host.exe` | Run as a console application (foreground) |
|
||||||
|
| `LmxOpcUa.Host.exe install` | Install as a Windows service |
|
||||||
|
| `LmxOpcUa.Host.exe uninstall` | Remove the Windows service |
|
||||||
|
| `LmxOpcUa.Host.exe start` | Start the installed service |
|
||||||
|
| `LmxOpcUa.Host.exe stop` | Stop the installed service |
|
||||||
|
|
||||||
|
The service is configured to run as `LocalSystem` and start automatically on boot.
|
||||||
|
|
||||||
|
## Working Directory
|
||||||
|
|
||||||
|
Before configuring Serilog, `Program.Main()` sets the working directory to the executable's location:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Environment.CurrentDirectory = AppDomain.CurrentDomain.BaseDirectory;
|
||||||
|
```
|
||||||
|
|
||||||
|
This is necessary because Windows services default their working directory to `System32`, which would cause relative log paths and `appsettings.json` to resolve incorrectly.
|
||||||
|
|
||||||
|
## Startup Sequence
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
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 optional historian data source.
|
||||||
|
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.
|
||||||
|
13. **Log startup complete** -- Logs "LmxOpcUa service started successfully" at `Information` level.
|
||||||
|
|
||||||
|
## Shutdown Sequence
|
||||||
|
|
||||||
|
`OpcUaService.Stop()` tears down components in reverse dependency order:
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Unhandled exceptions
|
||||||
|
|
||||||
|
`AppDomain.CurrentDomain.UnhandledException` is registered at startup and removed at shutdown. The handler logs the exception at `Fatal` level with the `IsTerminating` flag:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Log.Fatal(e.ExceptionObject as Exception,
|
||||||
|
"Unhandled exception (IsTerminating={IsTerminating})", e.IsTerminating);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Startup resilience
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
122
docs/StatusDashboard.md
Normal file
122
docs/StatusDashboard.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Status Dashboard
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## HTTP Server
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Path | Content-Type | Description |
|
||||||
|
|------|-------------|-------------|
|
||||||
|
| `/` | `text/html` | HTML dashboard with auto-refresh |
|
||||||
|
| `/api/status` | `application/json` | Full status snapshot as JSON |
|
||||||
|
| `/api/health` | `application/json` | Health check: returns `200` with `{"status":"healthy"}` or `503` with `{"status":"unhealthy"}` |
|
||||||
|
|
||||||
|
Any other path returns `404 Not Found`.
|
||||||
|
|
||||||
|
## Health Check Logic
|
||||||
|
|
||||||
|
`HealthCheckService` evaluates bridge health using two rules applied in order:
|
||||||
|
|
||||||
|
1. **Unhealthy** -- MXAccess connection state is not `Connected`. Returns a red banner with the current state.
|
||||||
|
2. **Degraded** -- Any recorded operation has more than 100 total invocations and a success rate below 50%. Returns a yellow banner identifying the failing operation.
|
||||||
|
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 |
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Footer
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `Timestamp` | `DateTime` | UTC time when the snapshot was generated |
|
||||||
|
| `Version` | `string` | Service assembly version |
|
||||||
|
|
||||||
|
## HTML Dashboard
|
||||||
|
|
||||||
|
The HTML dashboard uses a monospace font on a dark background with color-coded panels. Each status section renders as a bordered panel whose border color reflects the component state (green, yellow, red, or gray). The operations table shows per-operation latency and success rate statistics.
|
||||||
|
|
||||||
|
The page includes a `<meta http-equiv='refresh'>` tag set to the configured `RefreshIntervalSeconds` (default 10 seconds), so the browser polls automatically without JavaScript.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Component Wiring
|
||||||
|
|
||||||
|
`StatusReportService` is initialized after all other service components are created. `OpcUaService.Start()` calls `SetComponents()` to supply the live references:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
_statusReport.SetComponents(effectiveMxClient, _metrics, _galaxyStats, _serverHost, _nodeManager);
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
135
docs/Subscriptions.md
Normal file
135
docs/Subscriptions.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
```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, (_, _) => { });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UnsubscribeTag
|
||||||
|
|
||||||
|
`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:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (count <= 1)
|
||||||
|
{
|
||||||
|
_subscriptionRefCounts.Remove(fullTagReference);
|
||||||
|
_ = _mxAccessClient.UnsubscribeAsync(fullTagReference);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
_subscriptionRefCounts[fullTagReference] = count - 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## OnMonitoredItemCreated
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`OnDeleteMonitoredItemsComplete` performs the inverse, calling `UnsubscribeTag` for each deleted monitored item.
|
||||||
|
|
||||||
|
## Data Change Dispatch Queue
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Callback handler
|
||||||
|
|
||||||
|
`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:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void OnMxAccessDataChange(string address, Vtq vtq)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _totalMxChangeEvents);
|
||||||
|
_pendingDataChanges[address] = vtq;
|
||||||
|
_dataChangeSignal.Set();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dispatch thread architecture
|
||||||
|
|
||||||
|
A dedicated background thread (`OpcUaDataChangeDispatch`) runs `DispatchLoop`, which waits on an `AutoResetEvent` with a 100ms timeout. The decoupled design exists for two reasons:
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The dispatch loop processes changes in two phases:
|
||||||
|
|
||||||
|
**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).
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
# Implementation Plan: LmxOpcUa Server — All 44 Requirements
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The LmxOpcUa project is scaffolded (solution, projects, configs, requirements docs) but has no implementation beyond Program.cs and a stub OpcUaService.cs. This plan implements all 44 requirements across 6 phases, each with verification gates and wiring checks to ensure nothing is left unconnected.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Five major components wired together in OpcUaService.cs:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
|
||||||
│ Galaxy Repository│────>│ OPC UA Server │<───>│ OPC UA Clients │
|
|
||||||
│ (SQL queries) │ │ (address space) │ │ │
|
|
||||||
└─────────────────┘ └────────┬──────────┘ └─────────────────┘
|
|
||||||
│
|
|
||||||
┌────────┴──────────┐
|
|
||||||
│ MxAccessClient │
|
|
||||||
│ (STA + COM) │
|
|
||||||
└───────────────────┘
|
|
||||||
│
|
|
||||||
┌────────┴──────────┐
|
|
||||||
│ Status Dashboard │
|
|
||||||
│ (HTTP + metrics) │
|
|
||||||
└───────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
Reference implementation: `C:\Users\dohertj2\Desktop\scadalink-design\lmxproxy\src\ZB.MOM.WW.LmxProxy.Host\`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PHASE 1: Foundation — Domain Models, Configuration, Interfaces
|
|
||||||
|
|
||||||
**Reqs:** SVC-003, SVC-006 (partial), MXA-008 (interfaces), MXA-009, OPC-005, OPC-012 (partial), GR-005 (config)
|
|
||||||
|
|
||||||
### Files to Create
|
|
||||||
|
|
||||||
**Configuration/**
|
|
||||||
- `AppConfiguration.cs` — top-level holder for all config sections
|
|
||||||
- `OpcUaConfiguration.cs` — Port, EndpointPath, ServerName, GalaxyName, MaxSessions, SessionTimeoutMinutes
|
|
||||||
- `MxAccessConfiguration.cs` — ClientName, timeouts, concurrency, probe settings
|
|
||||||
- `GalaxyRepositoryConfiguration.cs` — ConnectionString, intervals, command timeout
|
|
||||||
- `DashboardConfiguration.cs` — Enabled, Port, RefreshIntervalSeconds
|
|
||||||
- `ConfigurationValidator.cs` — validate and log effective config at startup
|
|
||||||
|
|
||||||
**Domain/**
|
|
||||||
- `ConnectionState.cs` — enum: Disconnected, Connecting, Connected, Disconnecting, Error, Reconnecting
|
|
||||||
- `ConnectionStateChangedEventArgs.cs` — PreviousState, CurrentState, Message
|
|
||||||
- `Vtq.cs` — Value/Timestamp/Quality struct with factory methods
|
|
||||||
- `Quality.cs` — enum with Bad/Uncertain/Good families matching OPC DA codes
|
|
||||||
- `QualityMapper.cs` — MapFromMxAccessQuality(int) and MapToOpcUaStatusCode(Quality)
|
|
||||||
- `MxDataTypeMapper.cs` — MapToOpcUaDataType(int mxDataType), MapToClrType(int). Unknown defaults to String
|
|
||||||
- `MxErrorCodes.cs` — translate 1008/1012/1013 to human messages
|
|
||||||
- `GalaxyObjectInfo.cs` — DTO matching hierarchy.sql columns
|
|
||||||
- `GalaxyAttributeInfo.cs` — DTO matching attributes.sql columns
|
|
||||||
- `IMxAccessClient.cs` — interface: Connect, Disconnect, Subscribe, Read, Write, OnTagValueChanged delegate
|
|
||||||
- `IGalaxyRepository.cs` — interface: GetHierarchy, GetAttributes, GetLastDeployTime, TestConnection, OnGalaxyChanged event
|
|
||||||
- `IMxProxy.cs` — abstraction over LMXProxyServer COM object (enables testing without DLL)
|
|
||||||
|
|
||||||
**Metrics/**
|
|
||||||
- `PerformanceMetrics.cs` — ITimingScope, OperationMetrics (1000-entry rolling buffer), BeginOperation/GetStatistics. Adapt from reference.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- `ConfigurationLoadingTests.cs` — bind appsettings.json, verify defaults
|
|
||||||
- `MxDataTypeMapperTests.cs` — all 12 type mappings + unknown default
|
|
||||||
- `QualityMapperTests.cs` — boundary values (0, 63, 64, 191, 192)
|
|
||||||
- `MxErrorCodesTests.cs` — known codes + unknown
|
|
||||||
- `PerformanceMetricsTests.cs` — recording, P95, buffer eviction, empty state
|
|
||||||
|
|
||||||
### Verification Gate 1
|
|
||||||
- [ ] `dotnet build` — zero errors
|
|
||||||
- [ ] All Phase 1 tests pass
|
|
||||||
- [ ] Config binding loads all 4 sections from appsettings.json
|
|
||||||
- [ ] MxDataTypeMapper covers every row in `gr/data_type_mapping.md`
|
|
||||||
- [ ] Quality enum covers all reference impl values
|
|
||||||
- [ ] Builds WITHOUT ArchestrA.MxAccess.dll (interface-based, no COM refs in Phase 1)
|
|
||||||
- [ ] Every new file has doc-comment referencing requirement ID(s)
|
|
||||||
- [ ] IMxAccessClient has every method needed by OPC-007, OPC-008, OPC-009
|
|
||||||
- [ ] IGalaxyRepository has every method needed by GR-001 through GR-004
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PHASE 2: MxAccessClient — STA Thread and COM Interop
|
|
||||||
|
|
||||||
**Reqs:** MXA-001, MXA-002, MXA-003, MXA-004, MXA-005, MXA-006, MXA-007, MXA-008 (wiring)
|
|
||||||
|
|
||||||
### Files to Create
|
|
||||||
|
|
||||||
**MxAccess/**
|
|
||||||
- `StaComThread.cs` — adapt from reference. STA thread, Win32 message pump, RunAsync(Action)/RunAsync<T>(Func<T>), WM_APP dispatch
|
|
||||||
- `MxAccessClient.cs` — core partial class implementing IMxAccessClient. Fields: StaComThread, IMxProxy, handle, state, semaphores, maps
|
|
||||||
- `MxAccessClient.Connection.cs` — ConnectAsync (Register on STA), DisconnectAsync (cleanup per MXA-007), COM cleanup
|
|
||||||
- `MxAccessClient.Subscription.cs` — SubscribeAsync (AddItem+AdviseSupervisory), UnsubscribeAsync, ReplayStoredSubscriptions
|
|
||||||
- `MxAccessClient.ReadWrite.cs` — ReadAsync (subscribe-get-first-unsubscribe), WriteAsync (Write+OnWriteComplete), semaphore-limited, timeout, ITimingScope metrics
|
|
||||||
- `MxAccessClient.EventHandlers.cs` — OnDataChange (resolve handle→address, create Vtq, invoke callback, update probe), OnWriteComplete (complete TCS, translate errors)
|
|
||||||
- `MxAccessClient.Monitor.cs` — monitor loop (reconnect on disconnect, probe staleness→force reconnect), cancellable
|
|
||||||
- `MxProxyAdapter.cs` — wraps real LMXProxyServer COM object, forwards calls to IMxProxy interface
|
|
||||||
|
|
||||||
**Test Helpers (in Tests project):**
|
|
||||||
- `FakeMxProxy.cs` — implements IMxProxy, simulates connections/data changes for testing
|
|
||||||
|
|
||||||
### Design Decision: IMxProxy Abstraction
|
|
||||||
Code against `IMxProxy` interface (not `LMXProxyServer` directly). This allows testing without ArchestrA.MxAccess.dll. `MxProxyAdapter` wraps the real COM object at runtime.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- `StaComThreadTests.cs` — STA apartment verified, work item execution, dispose
|
|
||||||
- `MxAccessClientConnectionTests.cs` — state transitions, cleanup order
|
|
||||||
- `MxAccessClientSubscriptionTests.cs` — subscribe/unsubscribe, stored subscriptions, reconnect replay, OnDataChange→callback
|
|
||||||
- `MxAccessClientReadWriteTests.cs` — read returns value, read timeout, write completes on callback, write timeout, semaphore limiting
|
|
||||||
- `MxAccessClientMonitorTests.cs` — reconnect on disconnect, probe staleness
|
|
||||||
|
|
||||||
### Verification Gate 2
|
|
||||||
- [ ] Solution builds without ArchestrA.MxAccess.dll
|
|
||||||
- [ ] STA thread test proves work items execute on STA apartment
|
|
||||||
- [ ] Connection lifecycle: Disconnected→Connecting→Connected→Disconnecting→Disconnected
|
|
||||||
- [ ] Subscription replay: stored subscriptions replayed after simulated reconnect
|
|
||||||
- [ ] Read/Write: timeout behavior returns error within expected window
|
|
||||||
- [ ] Metrics: Read/Write record timing in PerformanceMetrics
|
|
||||||
- [ ] **WIRING CHECK:** OnDataChange callback reaches OnTagValueChanged delegate
|
|
||||||
- [ ] COM cleanup order: UnAdvise→RemoveItem→unwire events→Unregister→ReleaseComObject
|
|
||||||
- [ ] Error codes 1008/1012/1013 translate correctly in OnWriteComplete path
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PHASE 3: Galaxy Repository — SQL Queries and Change Detection
|
|
||||||
|
|
||||||
**Reqs:** GR-001, GR-002, GR-003, GR-004, GR-006, GR-007
|
|
||||||
|
|
||||||
### Files to Create
|
|
||||||
|
|
||||||
**GalaxyRepository/**
|
|
||||||
- `GalaxyRepositoryService.cs` — implements IGalaxyRepository. SQL embedded as `const string` (from gr/queries/). ADO.NET SqlConnection per-query. GetHierarchyAsync, GetAttributesAsync, GetLastDeployTimeAsync, TestConnectionAsync
|
|
||||||
- `ChangeDetectionService.cs` — background Timer at configured interval. Polls GetLastDeployTimeAsync, compares to last known, fires OnGalaxyChanged on change. First poll always triggers. Failed poll logs Warning, retries next interval
|
|
||||||
- `GalaxyRepositoryStats.cs` — POCO for dashboard: GalaxyName, DbConnected, LastDeployTime, ObjectCount, AttributeCount, LastRebuildTime
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- `ChangeDetectionServiceTests.cs` — first poll triggers, same timestamp skips, changed triggers, failed poll retries
|
|
||||||
- `GalaxyRepositoryServiceTests.cs` (integration, in IntegrationTests) — TestConnection, GetHierarchy returns rows, GetAttributes returns rows
|
|
||||||
|
|
||||||
### Verification Gate 3
|
|
||||||
- [ ] All SQL is `const string` — no concatenation, no parameters, no INSERT/UPDATE/DELETE (GR-006 code review)
|
|
||||||
- [ ] GetHierarchyAsync maps all columns: gobject_id, tag_name, contained_name, browse_name, parent_gobject_id, is_area
|
|
||||||
- [ ] GetAttributesAsync maps all columns including array_dimension
|
|
||||||
- [ ] Change detection: first poll fires, same timestamp skips, changed fires
|
|
||||||
- [ ] Failed query does NOT crash or trigger false rebuild
|
|
||||||
- [ ] GalaxyRepositoryStats populated for dashboard
|
|
||||||
- [ ] Zero rows from hierarchy logs Warning
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PHASE 4: OPC UA Server — Address Space and Node Manager
|
|
||||||
|
|
||||||
**Reqs:** OPC-001, OPC-002, OPC-003, OPC-004, OPC-005, OPC-006, OPC-007, OPC-008, OPC-009, OPC-010, OPC-011, OPC-012, OPC-013
|
|
||||||
|
|
||||||
### Files to Create
|
|
||||||
|
|
||||||
**OpcUa/**
|
|
||||||
- `LmxOpcUaServer.cs` — inherits StandardServer. Creates custom node manager. SecurityPolicy None. Registers namespace `urn:{GalaxyName}:LmxOpcUa`
|
|
||||||
- `LmxNodeManager.cs` — inherits CustomNodeManager2. Core class:
|
|
||||||
- `BuildAddressSpace(hierarchy, attributes)` — creates folder/object/variable nodes from Galaxy data. NodeId: `ns=1;s={tag_name}` / `ns=1;s={tag_name}.{attr}`. Stores full_tag_reference lookup
|
|
||||||
- `RebuildAddressSpace(hierarchy, attributes)` — removes old nodes, rebuilds. Preserves sessions
|
|
||||||
- Read/Write overrides delegate to IMxAccessClient via stored full_tag_reference
|
|
||||||
- Subscription management: ref-counted shared MXAccess subscriptions
|
|
||||||
- `OpcUaServerHost.cs` — manages ApplicationInstance lifecycle. Programmatic config (no XML). Start/Stop. Exposes ActiveSessionCount
|
|
||||||
- `OpcUaQualityMapper.cs` — domain Quality → OPC UA StatusCodes
|
|
||||||
- `DataValueConverter.cs` — COM variant ↔ OPC UA DataValue. Handles all types from data_type_mapping.md. DateTime UTC. Arrays
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- `DataValueConverterTests.cs` — all type conversions, arrays, DateTime UTC
|
|
||||||
- `LmxNodeManagerBuildTests.cs` — synthetic hierarchy matching gr/layout.md, verify node types, NodeIds, data types, ValueRank, ArrayDimensions
|
|
||||||
- `LmxNodeManagerRebuildTests.cs` — rebuild replaces nodes, old nodes gone, new nodes present
|
|
||||||
- `OpcUaQualityMapperTests.cs` — all quality families
|
|
||||||
|
|
||||||
### Verification Gate 4
|
|
||||||
- [ ] Endpoint URL: `opc.tcp://{hostname}:{port}/LmxOpcUa`
|
|
||||||
- [ ] Namespace: `urn:{GalaxyName}:LmxOpcUa` at index 1
|
|
||||||
- [ ] Root ZB folder under Objects
|
|
||||||
- [ ] Areas → FolderType + Organizes reference
|
|
||||||
- [ ] Non-areas → BaseObjectType + HasComponent reference
|
|
||||||
- [ ] Variable nodes: correct DataType, ValueRank, ArrayDimensions per data_type_mapping.md
|
|
||||||
- [ ] **WIRING CHECK:** Read handler resolves NodeId → full_tag_reference → calls IMxAccessClient.ReadAsync
|
|
||||||
- [ ] **WIRING CHECK:** Write handler resolves NodeId → full_tag_reference → calls IMxAccessClient.WriteAsync
|
|
||||||
- [ ] Rebuild removes old nodes, creates new ones without crash
|
|
||||||
- [ ] SecurityPolicy is None
|
|
||||||
- [ ] MaxSessions/SessionTimeout configured from appsettings
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PHASE 5: Status Dashboard — HTTP, HTML, JSON, Health
|
|
||||||
|
|
||||||
**Reqs:** DASH-001 through DASH-009
|
|
||||||
|
|
||||||
### Files to Create
|
|
||||||
|
|
||||||
**Status/**
|
|
||||||
- `StatusData.cs` — DTO: ConnectionInfo, HealthInfo, SubscriptionInfo, GalaxyInfo, OperationMetrics, Footer
|
|
||||||
- `HealthCheckService.cs` — rules: not connected→Unhealthy, success rate<50% w/>100 ops→Degraded, else Healthy
|
|
||||||
- `StatusReportService.cs` — aggregates from all components. GenerateHtml (self-contained, inline CSS, color-coded panels, meta-refresh). GenerateJson. IsHealthy
|
|
||||||
- `StatusWebServer.cs` — HttpListener. Routes: / → HTML, /api/status → JSON, /api/health → 200/503. GET only. no-cache headers. Disableable
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- `HealthCheckServiceTests.cs` — three health rules, messages
|
|
||||||
- `StatusReportServiceTests.cs` — HTML contains all panels, JSON deserializes, meta-refresh tag
|
|
||||||
- `StatusWebServerTests.cs` — routing (200/405/404), cache headers, start/stop
|
|
||||||
|
|
||||||
### Verification Gate 5
|
|
||||||
- [ ] HTML contains all panels: Connection, Health, Subscriptions, Galaxy Info, Operations table, Footer
|
|
||||||
- [ ] Connection panel: green/red/yellow border per state
|
|
||||||
- [ ] Health panel: three states with correct colors
|
|
||||||
- [ ] Operations table: Read/Write/Subscribe/Browse with Count/SuccessRate/Avg/Min/Max/P95
|
|
||||||
- [ ] Galaxy Info panel: galaxy name, DB status, last deploy, object/attribute counts, last rebuild
|
|
||||||
- [ ] Footer: timestamp + assembly version
|
|
||||||
- [ ] JSON API: all same data as HTML
|
|
||||||
- [ ] /api/health: 200 when healthy, 503 when unhealthy
|
|
||||||
- [ ] Meta-refresh tag with configured interval
|
|
||||||
- [ ] Port conflict does not prevent service startup
|
|
||||||
- [ ] Dashboard disabled via config skips HttpListener
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PHASE 6: Integration Wiring and End-to-End Verification
|
|
||||||
|
|
||||||
**Reqs:** SVC-004, SVC-005, SVC-006, ALL wiring verification
|
|
||||||
|
|
||||||
### OpcUaService.cs — Full Implementation
|
|
||||||
|
|
||||||
**Start() sequence (SVC-005):**
|
|
||||||
1. Load AppConfiguration via IConfiguration
|
|
||||||
2. ConfigurationValidator.ValidateAndLog()
|
|
||||||
3. Register AppDomain.UnhandledException handler (SVC-006)
|
|
||||||
4. Create PerformanceMetrics
|
|
||||||
5. Create MxAccessClient → ConnectAsync (failure = fatal, don't start)
|
|
||||||
6. Start MxAccessClient monitor loop
|
|
||||||
7. Create GalaxyRepositoryService → TestConnectionAsync (failure = warning, continue)
|
|
||||||
8. Create OpcUaServerHost + LmxNodeManager, inject IMxAccessClient
|
|
||||||
9. Query initial hierarchy + attributes → BuildAddressSpace
|
|
||||||
10. Start OPC UA server listener (failure = fatal)
|
|
||||||
11. Create ChangeDetectionService → **wire OnGalaxyChanged → nodeManager.RebuildAddressSpace**
|
|
||||||
12. Start change detection polling
|
|
||||||
13. Create HealthCheckService, StatusReportService, StatusWebServer → Start (failure = warning)
|
|
||||||
14. Log "LmxOpcUa service started successfully"
|
|
||||||
|
|
||||||
**Critical wiring (GUARDRAILS):**
|
|
||||||
- `_mxAccessClient.OnTagValueChanged` → node manager subscription delivery
|
|
||||||
- `_changeDetectionService.OnGalaxyChanged` → `_nodeManager.RebuildAddressSpace`
|
|
||||||
- `_mxAccessClient.ConnectionStateChanged` → health check updates
|
|
||||||
- Node manager Read/Write → `_mxAccessClient.ReadAsync/WriteAsync`
|
|
||||||
- StatusReportService reads from: MxAccessClient, PerformanceMetrics, GalaxyRepositoryStats, OpcUaServerHost
|
|
||||||
|
|
||||||
**Stop() sequence (SVC-004, reverse order, 30s max):**
|
|
||||||
1. Cancel CancellationTokenSource (stops all background loops)
|
|
||||||
2. Stop change detection
|
|
||||||
3. Stop OPC UA server
|
|
||||||
4. Disconnect MXAccess (full COM cleanup)
|
|
||||||
5. Stop StatusWebServer
|
|
||||||
6. Dispose PerformanceMetrics
|
|
||||||
7. Log "Service shutdown complete"
|
|
||||||
|
|
||||||
### Wiring Verification Tests (GUARDRAILS)
|
|
||||||
|
|
||||||
These tests prove components are connected end-to-end, not just implemented in isolation:
|
|
||||||
|
|
||||||
- `Wiring/MxAccessToNodeManagerWiringTest.cs` — simulate OnDataChange on FakeMxProxy → verify data reaches node manager subscription delivery
|
|
||||||
- `Wiring/ChangeDetectionToRebuildWiringTest.cs` — mock GalaxyRepository returns changed timestamp → verify RebuildAddressSpace called
|
|
||||||
- `Wiring/OpcUaReadToMxAccessWiringTest.cs` — issue Read via NodeManager → verify FakeMxProxy receives correct full_tag_reference
|
|
||||||
- `Wiring/OpcUaWriteToMxAccessWiringTest.cs` — issue Write via NodeManager → verify FakeMxProxy receives correct tag + value
|
|
||||||
- `Wiring/ServiceStartupSequenceTest.cs` — create OpcUaService with fakes, call Start(), verify all components created and wired
|
|
||||||
- `Wiring/ShutdownCompletesTest.cs` — Start then Stop, verify completes within 30s
|
|
||||||
- `EndToEnd/FullDataFlowTest.cs` — **THE ULTIMATE SMOKE TEST**: full service with fakes, verify: (1) address space built, (2) MXAccess data change → OPC UA variable, (3) read → correct tag ref, (4) write → correct tag+value, (5) dashboard HTML has real data
|
|
||||||
|
|
||||||
### Verification Gate 6 (FINAL)
|
|
||||||
- [ ] Startup: all 14 steps execute in order
|
|
||||||
- [ ] Shutdown: completes within 30s, all components disposed in reverse order
|
|
||||||
- [ ] **WIRING:** MXAccess OnDataChange → node manager subscription delivery
|
|
||||||
- [ ] **WIRING:** Galaxy change → address space rebuild
|
|
||||||
- [ ] **WIRING:** OPC UA Read → MXAccess ReadAsync with correct tag reference
|
|
||||||
- [ ] **WIRING:** OPC UA Write → MXAccess WriteAsync with correct tag+value
|
|
||||||
- [ ] **WIRING:** Dashboard aggregates data from all components
|
|
||||||
- [ ] **WIRING:** Health endpoint reflects actual connection state
|
|
||||||
- [ ] AppDomain.UnhandledException registered
|
|
||||||
- [ ] TopShelf recovery configured (restart, 60s delay)
|
|
||||||
- [ ] FullDataFlowTest passes end-to-end
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Master Requirement Traceability (all 44)
|
|
||||||
|
|
||||||
| Req | Phase | Verified By |
|
|
||||||
|-----|-------|-------------|
|
|
||||||
| SVC-001 | Done | Program.cs already configured |
|
|
||||||
| SVC-002 | Done | Program.cs already configured |
|
|
||||||
| SVC-003 | 1 | ConfigurationLoadingTests |
|
|
||||||
| SVC-004 | 6 | ShutdownCompletesTest |
|
|
||||||
| SVC-005 | 6 | ServiceStartupSequenceTest |
|
|
||||||
| SVC-006 | 6 | AppDomain handler registration test |
|
|
||||||
| MXA-001 | 2 | StaComThreadTests |
|
|
||||||
| MXA-002 | 2 | MxAccessClientConnectionTests |
|
|
||||||
| MXA-003 | 2 | MxAccessClientSubscriptionTests |
|
|
||||||
| MXA-004 | 2 | MxAccessClientReadWriteTests |
|
|
||||||
| MXA-005 | 2 | MxAccessClientMonitorTests |
|
|
||||||
| MXA-006 | 2 | MxAccessClientMonitorTests (probe) |
|
|
||||||
| MXA-007 | 2 | Cleanup order test |
|
|
||||||
| MXA-008 | 2 | Metrics integration in ReadWrite |
|
|
||||||
| MXA-009 | 1+2 | MxErrorCodesTests + write error path |
|
|
||||||
| GR-001 | 3 | GetHierarchyAsync maps all columns |
|
|
||||||
| GR-002 | 3 | GetAttributesAsync maps all columns |
|
|
||||||
| GR-003 | 3 | ChangeDetectionServiceTests |
|
|
||||||
| GR-004 | 3+6 | ChangeDetectionToRebuildWiringTest |
|
|
||||||
| GR-005 | 1+3 | Config tests + ADO.NET usage |
|
|
||||||
| GR-006 | 3 | Code review: const string SQL only |
|
|
||||||
| GR-007 | 3 | TestConnectionAsync test |
|
|
||||||
| OPC-001 | 4 | Endpoint URL test |
|
|
||||||
| OPC-002 | 4 | BuildTests: node types + references |
|
|
||||||
| OPC-003 | 4 | BuildTests: variable nodes |
|
|
||||||
| OPC-004 | 4+6 | ReadWiringTest: browse→tag_name |
|
|
||||||
| OPC-005 | 1+4 | MxDataTypeMapperTests + variable node DataType |
|
|
||||||
| OPC-006 | 4 | BuildTests: ValueRank + ArrayDimensions |
|
|
||||||
| OPC-007 | 4+6 | OpcUaReadToMxAccessWiringTest |
|
|
||||||
| OPC-008 | 4+6 | OpcUaWriteToMxAccessWiringTest |
|
|
||||||
| OPC-009 | 4+6 | MxAccessToNodeManagerWiringTest |
|
|
||||||
| OPC-010 | 4+6 | RebuildTests + ChangeDetectionToRebuildWiringTest |
|
|
||||||
| OPC-011 | 4 | ServerStatus node test |
|
|
||||||
| OPC-012 | 4 | Namespace URI test |
|
|
||||||
| OPC-013 | 4 | Session config test |
|
|
||||||
| DASH-001 | 5 | StatusWebServerTests routing |
|
|
||||||
| DASH-002 | 5 | HTML contains Connection panel |
|
|
||||||
| DASH-003 | 5 | HealthCheckServiceTests |
|
|
||||||
| DASH-004 | 5 | HTML contains Subscriptions panel |
|
|
||||||
| DASH-005 | 5 | HTML contains Operations table |
|
|
||||||
| DASH-006 | 5 | HTML contains Footer |
|
|
||||||
| DASH-007 | 5 | Meta-refresh tag test |
|
|
||||||
| DASH-008 | 5 | JSON API deserialization test |
|
|
||||||
| DASH-009 | 5 | HTML contains Galaxy Info panel |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Final Folder Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/ZB.MOM.WW.LmxOpcUa.Host/
|
|
||||||
Configuration/ (Phase 1)
|
|
||||||
Domain/ (Phase 1)
|
|
||||||
Metrics/ (Phase 1)
|
|
||||||
MxAccess/ (Phase 2)
|
|
||||||
GalaxyRepository/ (Phase 3)
|
|
||||||
OpcUa/ (Phase 4)
|
|
||||||
Status/ (Phase 5)
|
|
||||||
OpcUaService.cs (Phase 6 — full wiring)
|
|
||||||
Program.cs (existing)
|
|
||||||
appsettings.json (existing)
|
|
||||||
tests/ZB.MOM.WW.LmxOpcUa.Tests/
|
|
||||||
Configuration/ (Phase 1)
|
|
||||||
Domain/ (Phase 1)
|
|
||||||
Metrics/ (Phase 1)
|
|
||||||
MxAccess/ (Phase 2)
|
|
||||||
GalaxyRepository/ (Phase 3)
|
|
||||||
OpcUa/ (Phase 4)
|
|
||||||
Status/ (Phase 5)
|
|
||||||
Wiring/ (Phase 6 — GUARDRAILS)
|
|
||||||
EndToEnd/ (Phase 6 — GUARDRAILS)
|
|
||||||
Helpers/FakeMxProxy.cs (Phase 2)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification: How to Run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build
|
|
||||||
dotnet build ZB.MOM.WW.LmxOpcUa.slnx
|
|
||||||
|
|
||||||
# All tests
|
|
||||||
dotnet test ZB.MOM.WW.LmxOpcUa.slnx
|
|
||||||
|
|
||||||
# Phase-specific (by namespace convention)
|
|
||||||
dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests --filter "FullyQualifiedName~Configuration"
|
|
||||||
dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests --filter "FullyQualifiedName~MxAccess"
|
|
||||||
dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests --filter "FullyQualifiedName~GalaxyRepository"
|
|
||||||
dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests --filter "FullyQualifiedName~OpcUa"
|
|
||||||
dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests --filter "FullyQualifiedName~Status"
|
|
||||||
dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests --filter "FullyQualifiedName~Wiring"
|
|
||||||
dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests --filter "FullyQualifiedName~EndToEnd"
|
|
||||||
|
|
||||||
# Integration tests (requires ZB database)
|
|
||||||
dotnet test tests/ZB.MOM.WW.LmxOpcUa.IntegrationTests
|
|
||||||
```
|
|
||||||
Reference in New Issue
Block a user