Add configurable non-transparent OPC UA server redundancy
Separates ApplicationUri from namespace identity so each instance in a redundant pair has a unique server URI while sharing the same Galaxy namespace. Exposes RedundancySupport, ServerUriArray, and dynamic ServiceLevel through the standard OPC UA server object. ServiceLevel is computed from role (Primary/Secondary) and runtime health (MXAccess and DB connectivity). Adds CLI redundancy command, second deployed service instance, and 31 new tests including paired-server integration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,10 @@ dotnet test --filter "FullyQualifiedName~MyTestClass.MyMethod" # single test
|
||||
|
||||
The server supports configurable OPC UA transport security via the `Security` section in `appsettings.json`. Phase 1 profiles: `None` (default), `Basic256Sha256-Sign`, `Basic256Sha256-SignAndEncrypt`. Security profiles are resolved by `SecurityProfileResolver` at startup. The server certificate is always created even for `None`-only deployments because `UserName` token encryption depends on it. See `docs/security.md` for the full guide.
|
||||
|
||||
## Redundancy
|
||||
|
||||
The server supports non-transparent warm/hot redundancy via the `Redundancy` section in `appsettings.json`. Two instances share the same Galaxy DB and MXAccess runtime but have unique `ApplicationUri` values. Each exposes `RedundancySupport`, `ServerUriArray`, and a dynamic `ServiceLevel` based on role and runtime health. The primary advertises a higher ServiceLevel than the secondary. See `docs/Redundancy.md` for the full guide.
|
||||
|
||||
## Library Preferences
|
||||
|
||||
- **Logging**: Serilog with rolling daily file sink
|
||||
|
||||
@@ -141,6 +141,7 @@ gr/ Galaxy repository docs, SQL queries, schema
|
||||
| [Service Hosting](docs/ServiceHosting.md) | TopShelf, startup/shutdown sequence, error handling |
|
||||
| [CLI Tool](docs/CliTool.md) | Connect, browse, read, write, subscribe, historyread, alarms commands |
|
||||
| [Security](docs/security.md) | Transport security profiles, certificate trust, production hardening |
|
||||
| [Redundancy](docs/Redundancy.md) | Non-transparent warm/hot redundancy, ServiceLevel, paired deployment |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
|
||||
@@ -222,3 +222,29 @@ The command builds an `EventFilter` with select clauses for 12 fields from the O
|
||||
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.
|
||||
|
||||
### redundancy
|
||||
|
||||
Reads the OPC UA redundancy state from a server: `RedundancySupport`, `ServiceLevel`, `ServerUriArray`, and `ApplicationUri`.
|
||||
|
||||
```bash
|
||||
dotnet run -- redundancy -u opc.tcp://localhost:4840/LmxOpcUa
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```text
|
||||
Redundancy Mode: Warm
|
||||
Service Level: 200
|
||||
Server URIs:
|
||||
- urn:localhost:LmxOpcUa:instance1
|
||||
- urn:localhost:LmxOpcUa:instance2
|
||||
Application URI: urn:localhost:LmxOpcUa:instance1
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-u` | OPC UA server endpoint URL (required) |
|
||||
| `-S` | Transport security mode (default: none) |
|
||||
| `-U` | Username for authentication |
|
||||
| `-P` | Password for authentication |
|
||||
|
||||
@@ -55,6 +55,7 @@ Controls the OPC UA server endpoint and session limits. Defined in `OpcUaConfigu
|
||||
| `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 |
|
||||
| `ApplicationUri` | `string?` | `null` | Explicit application URI for this server instance. Required when redundancy is enabled. Defaults to `urn:{GalaxyName}:LmxOpcUa` when null |
|
||||
|
||||
### MxAccess
|
||||
|
||||
@@ -156,6 +157,30 @@ Example — production deployment with encrypted transport:
|
||||
}
|
||||
```
|
||||
|
||||
### Redundancy
|
||||
|
||||
Controls non-transparent OPC UA redundancy. Defined in `RedundancyConfiguration`. See [Redundancy Guide](Redundancy.md) for detailed usage.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `Enabled` | `bool` | `false` | Enables redundancy mode and ServiceLevel computation |
|
||||
| `Mode` | `string` | `"Warm"` | Redundancy mode: `Warm` or `Hot` |
|
||||
| `Role` | `string` | `"Primary"` | Instance role: `Primary` (higher ServiceLevel) or `Secondary` |
|
||||
| `ServerUris` | `List<string>` | `[]` | ApplicationUri values for all servers in the redundant set |
|
||||
| `ServiceLevelBase` | `int` | `200` | Base ServiceLevel when healthy (1-255). Secondary receives base - 50 |
|
||||
|
||||
Example — two-instance redundant pair (Primary):
|
||||
|
||||
```json
|
||||
"Redundancy": {
|
||||
"Enabled": true,
|
||||
"Mode": "Warm",
|
||||
"Role": "Primary",
|
||||
"ServerUris": ["urn:localhost:LmxOpcUa:instance1", "urn:localhost:LmxOpcUa:instance2"],
|
||||
"ServiceLevelBase": 200
|
||||
}
|
||||
```
|
||||
|
||||
## Feature Flags
|
||||
|
||||
Three boolean properties act as feature flags that control optional subsystems:
|
||||
@@ -176,6 +201,10 @@ Three boolean properties act as feature flags that control optional subsystems:
|
||||
- Unknown security profile names are logged as warnings
|
||||
- `AutoAcceptClientCertificates = true` emits a warning
|
||||
- Only-`None` profile configuration emits a warning
|
||||
- `OpcUa.ApplicationUri` must be set when `Redundancy.Enabled = true`
|
||||
- `Redundancy.ServiceLevelBase` must be between 1 and 255
|
||||
- `Redundancy.ServerUris` should contain at least 2 entries when enabled
|
||||
- Local `ApplicationUri` should appear in `Redundancy.ServerUris`
|
||||
|
||||
If validation fails, the service throws `InvalidOperationException` and does not start.
|
||||
|
||||
@@ -206,7 +235,8 @@ Integration tests use this constructor to inject substitute implementations of `
|
||||
"GalaxyName": "ZB",
|
||||
"MaxSessions": 100,
|
||||
"SessionTimeoutMinutes": 30,
|
||||
"AlarmTrackingEnabled": false
|
||||
"AlarmTrackingEnabled": false,
|
||||
"ApplicationUri": null
|
||||
},
|
||||
"MxAccess": {
|
||||
"ClientName": "LmxOpcUa",
|
||||
@@ -249,6 +279,13 @@ Integration tests use this constructor to inject substitute implementations of `
|
||||
"MinimumCertificateKeySize": 2048,
|
||||
"PkiRootPath": null,
|
||||
"CertificateSubject": null
|
||||
},
|
||||
"Redundancy": {
|
||||
"Enabled": false,
|
||||
"Mode": "Warm",
|
||||
"Role": "Primary",
|
||||
"ServerUris": [],
|
||||
"ServiceLevelBase": 200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -19,7 +19,7 @@ The OPC UA server component hosts the Galaxy-backed namespace on a configurable
|
||||
|
||||
The resulting endpoint URL is `opc.tcp://{BindAddress}:{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`.
|
||||
The namespace URI follows the pattern `urn:{GalaxyName}:LmxOpcUa` and is used as the `ProductUri`. The `ApplicationUri` can be set independently via `OpcUa.ApplicationUri` to support redundant deployments where each instance needs a unique identity. When `ApplicationUri` is null, it defaults to the namespace URI.
|
||||
|
||||
## Programmatic ApplicationConfiguration
|
||||
|
||||
@@ -48,6 +48,16 @@ Supported Phase 1 profiles:
|
||||
|
||||
For production deployments, configure `["Basic256Sha256-SignAndEncrypt"]` or `["None", "Basic256Sha256-SignAndEncrypt"]` and set `AutoAcceptClientCertificates` to `false`. See the [Security Guide](security.md) for hardening details.
|
||||
|
||||
## Redundancy
|
||||
|
||||
When `Redundancy.Enabled = true`, `LmxOpcUaServer` exposes the standard OPC UA redundancy nodes on startup:
|
||||
|
||||
- `Server/ServerRedundancy/RedundancySupport` — set to `Warm` or `Hot` based on configuration
|
||||
- `Server/ServerRedundancy/ServerUriArray` — populated with the configured `ServerUris`
|
||||
- `Server/ServiceLevel` — computed dynamically from role and runtime health
|
||||
|
||||
The `ServiceLevel` is updated whenever MXAccess connection state changes or Galaxy DB health changes. See [Redundancy Guide](Redundancy.md) for full details.
|
||||
|
||||
### User token policies
|
||||
|
||||
`UserTokenPolicies` are dynamically configured based on the `Authentication` settings in `appsettings.json`:
|
||||
|
||||
175
docs/Redundancy.md
Normal file
175
docs/Redundancy.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Redundancy
|
||||
|
||||
## Overview
|
||||
|
||||
LmxOpcUa supports OPC UA **non-transparent redundancy** in Warm or Hot mode. In a non-transparent redundancy deployment, two independent server instances run side by side. Both connect to the same Galaxy repository database and the same MXAccess runtime, but each maintains its own OPC UA sessions and subscriptions. Clients discover the redundant set through the `ServerUriArray` exposed in each server's address space and are responsible for managing failover between the two endpoints.
|
||||
|
||||
When redundancy is disabled (the default), the server reports `RedundancySupport.None` and a fixed `ServiceLevel` of 255.
|
||||
|
||||
## Namespace vs Application Identity
|
||||
|
||||
Both servers in the redundant set share the same **namespace URI** so that clients see identical node IDs regardless of which instance they are connected to. The namespace URI follows the pattern `urn:{GalaxyName}:LmxOpcUa` (e.g., `urn:ZB:LmxOpcUa`).
|
||||
|
||||
The **ApplicationUri**, on the other hand, must be unique per instance. This is how the OPC UA stack and clients distinguish one server from the other within the redundant set. Each instance sets its own ApplicationUri via the `OpcUa.ApplicationUri` configuration property (e.g., `urn:localhost:LmxOpcUa:instance1` and `urn:localhost:LmxOpcUa:instance2`).
|
||||
|
||||
When redundancy is disabled, `ApplicationUri` defaults to `urn:{GalaxyName}:LmxOpcUa` if left null.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Redundancy Section
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `Enabled` | bool | `false` | Enables non-transparent redundancy. When false, the server reports `RedundancySupport.None` and `ServiceLevel = 255`. |
|
||||
| `Mode` | string | `"Warm"` | The redundancy mode advertised to clients. Valid values: `Warm`, `Hot`. |
|
||||
| `Role` | string | `"Primary"` | This instance's role in the redundant pair. Valid values: `Primary`, `Secondary`. The Primary advertises a higher ServiceLevel than the Secondary when both are healthy. |
|
||||
| `ServerUris` | string[] | `[]` | The ApplicationUri values of all servers in the redundant set. Must include this instance's own `OpcUa.ApplicationUri`. Should contain at least 2 entries. |
|
||||
| `ServiceLevelBase` | int | `200` | The base ServiceLevel when the server is fully healthy. Valid range: 1-255. The Secondary automatically receives `ServiceLevelBase - 50`. |
|
||||
|
||||
### OpcUa.ApplicationUri
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `ApplicationUri` | string | `null` | Explicit application URI for this server instance. When null, defaults to `urn:{GalaxyName}:LmxOpcUa`. **Required when redundancy is enabled** -- each instance needs a unique identity. |
|
||||
|
||||
## ServiceLevel Computation
|
||||
|
||||
ServiceLevel is a standard OPC UA diagnostic value (0-255) that indicates server health. Clients in a redundant deployment should prefer the server advertising the highest ServiceLevel.
|
||||
|
||||
**Baseline values:**
|
||||
|
||||
| Role | Baseline |
|
||||
|---|---|
|
||||
| Primary | `ServiceLevelBase` (default 200) |
|
||||
| Secondary | `ServiceLevelBase - 50` (default 150) |
|
||||
|
||||
**Penalties applied to the baseline:**
|
||||
|
||||
| Condition | Penalty |
|
||||
|---|---|
|
||||
| MXAccess disconnected | -100 |
|
||||
| Galaxy DB unreachable | -50 |
|
||||
| Both MXAccess and DB down | ServiceLevel forced to 0 |
|
||||
|
||||
The final value is clamped to the range 0-255.
|
||||
|
||||
**Examples (with default ServiceLevelBase = 200):**
|
||||
|
||||
| Scenario | Primary | Secondary |
|
||||
|---|---|---|
|
||||
| Both healthy | 200 | 150 |
|
||||
| MXAccess down | 100 | 50 |
|
||||
| DB down | 150 | 100 |
|
||||
| Both down | 0 | 0 |
|
||||
|
||||
## Two-Instance Deployment
|
||||
|
||||
When deploying a redundant pair, the following configuration properties must differ between the two instances. All other settings (GalaxyName, ConnectionString, etc.) are shared.
|
||||
|
||||
| Property | Instance 1 (Primary) | Instance 2 (Secondary) |
|
||||
|---|---|---|
|
||||
| `OpcUa.Port` | 4840 | 4841 |
|
||||
| `OpcUa.ServerName` | `LmxOpcUa-1` | `LmxOpcUa-2` |
|
||||
| `OpcUa.ApplicationUri` | `urn:localhost:LmxOpcUa:instance1` | `urn:localhost:LmxOpcUa:instance2` |
|
||||
| `Dashboard.Port` | 8081 | 8082 |
|
||||
| `MxAccess.ClientName` | `LmxOpcUa-1` | `LmxOpcUa-2` |
|
||||
| `Redundancy.Role` | `Primary` | `Secondary` |
|
||||
|
||||
### Instance 1 -- Primary (appsettings.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"OpcUa": {
|
||||
"Port": 4840,
|
||||
"ServerName": "LmxOpcUa-1",
|
||||
"GalaxyName": "ZB",
|
||||
"ApplicationUri": "urn:localhost:LmxOpcUa:instance1"
|
||||
},
|
||||
"MxAccess": {
|
||||
"ClientName": "LmxOpcUa-1"
|
||||
},
|
||||
"Dashboard": {
|
||||
"Port": 8081
|
||||
},
|
||||
"Redundancy": {
|
||||
"Enabled": true,
|
||||
"Mode": "Warm",
|
||||
"Role": "Primary",
|
||||
"ServerUris": [
|
||||
"urn:localhost:LmxOpcUa:instance1",
|
||||
"urn:localhost:LmxOpcUa:instance2"
|
||||
],
|
||||
"ServiceLevelBase": 200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Instance 2 -- Secondary (appsettings.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"OpcUa": {
|
||||
"Port": 4841,
|
||||
"ServerName": "LmxOpcUa-2",
|
||||
"GalaxyName": "ZB",
|
||||
"ApplicationUri": "urn:localhost:LmxOpcUa:instance2"
|
||||
},
|
||||
"MxAccess": {
|
||||
"ClientName": "LmxOpcUa-2"
|
||||
},
|
||||
"Dashboard": {
|
||||
"Port": 8082
|
||||
},
|
||||
"Redundancy": {
|
||||
"Enabled": true,
|
||||
"Mode": "Warm",
|
||||
"Role": "Secondary",
|
||||
"ServerUris": [
|
||||
"urn:localhost:LmxOpcUa:instance1",
|
||||
"urn:localhost:LmxOpcUa:instance2"
|
||||
],
|
||||
"ServiceLevelBase": 200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CLI `redundancy` Command
|
||||
|
||||
The CLI tool at `tools/opcuacli-dotnet/` includes a `redundancy` command that reads the redundancy state from a running server.
|
||||
|
||||
```bash
|
||||
dotnet run -- redundancy -u opc.tcp://localhost:4840/LmxOpcUa
|
||||
dotnet run -- redundancy -u opc.tcp://localhost:4841/LmxOpcUa
|
||||
```
|
||||
|
||||
The command reads the following standard OPC UA nodes and displays their values:
|
||||
|
||||
- **Redundancy Mode** -- from `Server_ServerRedundancy_RedundancySupport` (None, Warm, or Hot)
|
||||
- **Service Level** -- from `Server_ServiceLevel` (0-255)
|
||||
- **Server URIs** -- from `Server_ServerRedundancy_ServerUriArray` (list of ApplicationUri values in the redundant set)
|
||||
- **Application URI** -- from `Server_ServerArray` (this instance's ApplicationUri)
|
||||
|
||||
Example output for a healthy Primary:
|
||||
|
||||
```
|
||||
Redundancy Mode: Warm
|
||||
Service Level: 200
|
||||
Server URIs:
|
||||
- urn:localhost:LmxOpcUa:instance1
|
||||
- urn:localhost:LmxOpcUa:instance2
|
||||
Application URI: urn:localhost:LmxOpcUa:instance1
|
||||
```
|
||||
|
||||
The command also supports `--username`/`--password` and `--security` options for authenticated or encrypted connections.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Mismatched ServerUris between instances** -- Both instances must list the exact same set of ApplicationUri values in `Redundancy.ServerUris`. If they differ, clients may not discover the full redundant set. Check the startup log for the `Redundancy.ServerUris` line on each instance.
|
||||
|
||||
**ServiceLevel stuck at 255** -- This indicates redundancy is not enabled. When `Redundancy.Enabled` is false (the default), the server always reports `ServiceLevel = 255` and `RedundancySupport.None`. Verify that `Redundancy.Enabled` is set to `true` in the configuration and that the configuration section is correctly bound.
|
||||
|
||||
**ApplicationUri not set** -- The configuration validator rejects startup when redundancy is enabled but `OpcUa.ApplicationUri` is null or empty. Each instance must have a unique ApplicationUri. Check the error log for: `OpcUa.ApplicationUri must be set when redundancy is enabled`.
|
||||
|
||||
**Both servers report the same ServiceLevel** -- Verify that one instance has `Redundancy.Role` set to `Primary` and the other to `Secondary`. Both set to `Primary` (or both to `Secondary`) will produce identical baseline values, preventing clients from distinguishing the preferred server.
|
||||
|
||||
**ServerUriArray not readable** -- When `RedundancySupport` is `None` (redundancy disabled), the OPC UA SDK may not expose the `ServerUriArray` node or it may return an empty value. The CLI `redundancy` command handles this gracefully by catching the read error. Enable redundancy to populate this array.
|
||||
@@ -132,6 +132,25 @@ Log files are written relative to the executable directory (see Working Director
|
||||
|
||||
`Log.CloseAndFlush()` is called in the `finally` block of `Program.Main()` to ensure all buffered log entries are written before process exit.
|
||||
|
||||
## Multi-Instance Deployment
|
||||
|
||||
The service supports running multiple instances for redundancy. Each instance requires:
|
||||
|
||||
- A unique Windows service name (e.g., `LmxOpcUa`, `LmxOpcUa2`)
|
||||
- A unique OPC UA port and dashboard port
|
||||
- A unique `OpcUa.ApplicationUri` and `OpcUa.ServerName`
|
||||
- A unique `MxAccess.ClientName`
|
||||
- Matching `Redundancy.ServerUris` arrays on all instances
|
||||
|
||||
Install additional instances using TopShelf's `-servicename` flag:
|
||||
|
||||
```bash
|
||||
cd C:\publish\lmxopcua\instance2
|
||||
ZB.MOM.WW.LmxOpcUa.Host.exe install -servicename "LmxOpcUa2" -displayname "LMX OPC UA Server (Instance 2)"
|
||||
```
|
||||
|
||||
See [Redundancy Guide](Redundancy.md) for full deployment details.
|
||||
|
||||
## 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.
|
||||
|
||||
561
redundancy.md
561
redundancy.md
@@ -2,75 +2,117 @@
|
||||
|
||||
## Summary
|
||||
|
||||
Add configurable non-transparent warm/hot redundancy to the LmxOpcUa server so that two instances sharing the same Galaxy repository can operate as a redundant pair. Each instance advertises itself and its partner through the OPC UA `ServerRedundancy` node, publishes a dynamic `ServiceLevel` reflecting runtime health, and allows clients to discover the redundant set and fail over between instances. The CLI tool gains a `redundancy` command for inspecting the redundant server set.
|
||||
Add configurable non-transparent warm/hot redundancy to the LmxOpcUa server so that two instances sharing the same Galaxy repository can operate as a redundant pair. Each instance should advertise the redundant set through the standard OPC UA redundancy nodes, publish a dynamic `ServiceLevel` based on runtime health, and allow clients to discover and fail over between the instances. The CLI tool should gain a `redundancy` command for inspecting the redundant server set.
|
||||
|
||||
This plan covers server-side redundancy exposure, client-side discovery, a second deployed service instance, documentation, and tests. It does **not** implement automatic server-side failover or subscription transfer — those are client responsibilities per the OPC UA specification.
|
||||
This review tightens the original draft in a few important ways:
|
||||
|
||||
- It separates **namespace identity** from **application identity**. The current host uses `urn:{GalaxyName}:LmxOpcUa` as both the namespace URI and `ApplicationUri`; that must change for redundancy because each server in the pair needs a unique server URI.
|
||||
- It avoids hand-wavy "write the redundancy nodes directly" language and instead targets the OPC UA SDK's built-in `ServerObjectState` / `ServerRedundancyState` model.
|
||||
- It removes a few inaccurate hardcoded assumptions, including the `ServerUriArray` node id and the deployment port examples.
|
||||
- It fixes execution order so test-builder and helper changes happen before integration coverage depends on them.
|
||||
|
||||
This plan still covers server-side redundancy exposure, client-side discovery, a second deployed service instance, documentation, and tests. It does **not** implement automatic server-side failover or subscription transfer; those remain client responsibilities per the OPC UA specification.
|
||||
|
||||
---
|
||||
|
||||
## Background: OPC UA Redundancy Model
|
||||
|
||||
OPC UA defines redundancy through three address-space nodes under `Server/ServerRedundancy`:
|
||||
OPC UA exposes redundancy through standard nodes under `Server/ServerRedundancy` plus the `Server/ServiceLevel` property:
|
||||
|
||||
| Node | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `RedundancySupport` | `RedundancySupport` enum | Declares the redundancy mode: `None`, `Cold`, `Warm`, `Hot`, `Transparent`, `HotAndMirrored` |
|
||||
| `ServerUriArray` | `String[]` | Lists the `ApplicationUri` values of all servers in the redundant set (non-transparent modes) |
|
||||
| `ServiceLevel` | `Byte` (0–255) | Indicates current operational quality; clients prefer the server with the highest value |
|
||||
| `ServerUriArray` | `String[]` | Lists the `ApplicationUri` values of all servers in the redundant set for non-transparent redundancy |
|
||||
| `ServiceLevel` | `Byte` (0-255) | Indicates current operational quality; clients prefer the server with the highest value |
|
||||
|
||||
### Non-Transparent Redundancy (our target)
|
||||
|
||||
In non-transparent redundancy (`Warm` or `Hot`), both servers run independently with their own sessions and subscriptions. Clients discover the redundant set by reading `ServerUriArray`, monitor `ServiceLevel` on each server, and manage their own failover. This model fits our architecture where each instance connects to the same Galaxy repository and MXAccess runtime independently.
|
||||
In non-transparent redundancy (`Warm` or `Hot`), both servers run independently with their own sessions and subscriptions. Clients discover the redundant set by reading `ServerUriArray`, monitor `ServiceLevel` on each server, and manage their own failover. This fits the current architecture, where each instance independently connects to the same Galaxy repository and MXAccess runtime.
|
||||
|
||||
### ServiceLevel Semantics
|
||||
### ServiceLevel semantics
|
||||
|
||||
| Range | Meaning |
|
||||
|---|---|
|
||||
| 0 | Server is not operational |
|
||||
| 1–99 | Degraded (e.g., MXAccess disconnected, DB unreachable) |
|
||||
| 100–199 | Healthy secondary |
|
||||
| 200–255 | Healthy primary (preferred) |
|
||||
| 1-99 | Degraded |
|
||||
| 100-199 | Healthy secondary |
|
||||
| 200-255 | Healthy primary |
|
||||
|
||||
The primary server should advertise a higher `ServiceLevel` than the secondary so clients prefer it when both are healthy.
|
||||
The primary should advertise a higher `ServiceLevel` than the secondary so clients prefer it when both are healthy.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
- `LmxOpcUaServer` extends `StandardServer` but does not override any redundancy-related methods
|
||||
- `ServerRedundancy/RedundancySupport` defaults to `None` (SDK default)
|
||||
- `ServiceLevel` defaults to `255` (SDK default — "fully operational")
|
||||
- No configuration for redundant partner URIs or role designation
|
||||
- Single deployed instance at `C:\publish\lmxopcua\instance1` on port 4840
|
||||
- No CLI support for reading redundancy information
|
||||
- `LmxOpcUaServer` extends `StandardServer` but does not expose redundancy state
|
||||
- `ServerRedundancy/RedundancySupport` remains the SDK default (`None`)
|
||||
- `Server/ServiceLevel` remains the SDK default (`255`)
|
||||
- No configuration exists for redundancy mode, role, or redundant partner URIs
|
||||
- `OpcUaServerHost` currently sets `ApplicationUri = urn:{GalaxyName}:LmxOpcUa`
|
||||
- `LmxNodeManager` uses the same `urn:{GalaxyName}:LmxOpcUa` as the published namespace URI
|
||||
- A single deployed instance is documented in [service_info.md](C:\Users\dohertj2\Desktop\lmxopcua\service_info.md)
|
||||
- No CLI command exists for reading redundancy information
|
||||
|
||||
## Key gap to fix first
|
||||
|
||||
For redundancy, each server in the set must advertise a unique `ApplicationUri`, and `ServerUriArray` must contain those unique values. The current implementation cannot do that because it reuses the namespace URI as the server `ApplicationUri`. Phase 1 therefore needs an application-identity change before the redundancy nodes can be correct.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope (Phase 1)
|
||||
### In scope (Phase 1)
|
||||
|
||||
1. **Redundancy configuration model** — role, partner URIs, ServiceLevel weights
|
||||
2. **Server redundancy node exposure** — `RedundancySupport`, `ServerUriArray`, dynamic `ServiceLevel`
|
||||
3. **ServiceLevel computation** — based on runtime health (MXAccess state, DB connectivity, role)
|
||||
4. **CLI redundancy command** — read `RedundancySupport`, `ServerUriArray`, `ServiceLevel` from a server
|
||||
5. **Second service instance** — deployed at `C:\publish\lmxopcua\instance2` with non-overlapping ports
|
||||
6. **Documentation** — new `docs/Redundancy.md` component doc, updates to existing docs
|
||||
7. **Unit tests** — config, ServiceLevel computation, resolver tests
|
||||
8. **Integration tests** — two-server redundancy E2E test in the integration test project
|
||||
1. Add explicit application-identity configuration so each instance can have a unique `ApplicationUri`
|
||||
2. Add redundancy configuration for mode, role, and server URI membership
|
||||
3. Expose `RedundancySupport`, `ServerUriArray`, and dynamic `ServiceLevel`
|
||||
4. Compute `ServiceLevel` from runtime health and preferred role
|
||||
5. Add a CLI `redundancy` command
|
||||
6. Document two-instance deployment
|
||||
7. Add unit and integration coverage
|
||||
|
||||
### Deferred
|
||||
|
||||
- Automatic subscription transfer (client-side responsibility)
|
||||
- Server-initiated failover (Galaxy `redundancy` table / engine flags)
|
||||
- Automatic subscription transfer
|
||||
- Server-initiated failover
|
||||
- Transparent redundancy mode
|
||||
- Health-check HTTP endpoint for load balancers
|
||||
- Load-balancer-specific HTTP health endpoints
|
||||
- Mirrored data/session state
|
||||
|
||||
---
|
||||
|
||||
## Configuration Design
|
||||
|
||||
### New `Redundancy` section in `appsettings.json`
|
||||
### 1. Add explicit `OpcUa.ApplicationUri`
|
||||
|
||||
**File:** `src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs`
|
||||
|
||||
Add:
|
||||
|
||||
```csharp
|
||||
public string? ApplicationUri { get; set; }
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `ApplicationUri = null` preserves the current behavior for non-redundant deployments
|
||||
- when `Redundancy.Enabled = true`, `ApplicationUri` must be explicitly set and unique per instance
|
||||
- `LmxNodeManager` should continue using `urn:{GalaxyName}:LmxOpcUa` as the namespace URI so both redundant servers expose the same namespace
|
||||
- `Redundancy.ServerUris` must contain the exact `ApplicationUri` values for all servers in the redundant set
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"OpcUa": {
|
||||
"ServerName": "LmxOpcUa",
|
||||
"GalaxyName": "ZB",
|
||||
"ApplicationUri": "urn:localhost:LmxOpcUa:instance1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. New `Redundancy` section in `appsettings.json`
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -84,7 +126,7 @@ The primary server should advertise a higher `ServiceLevel` than the secondary s
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration model
|
||||
### 3. Configuration model
|
||||
|
||||
**File:** `src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/RedundancyConfiguration.cs` (new)
|
||||
|
||||
@@ -99,13 +141,17 @@ public class RedundancyConfiguration
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration rules
|
||||
### 4. Configuration rules
|
||||
|
||||
- `Enabled` defaults to `false` for backward compatibility. When `false`, `RedundancySupport = None` and `ServiceLevel = 255` (SDK defaults).
|
||||
- `Mode` must be `Warm` or `Hot` (Phase 1). Maps to `RedundancySupport.Warm` or `RedundancySupport.Hot`.
|
||||
- `Role` must be `Primary` or `Secondary`. Controls the base `ServiceLevel` (Primary gets `ServiceLevelBase`, Secondary gets `ServiceLevelBase - 50`).
|
||||
- `ServerUris` lists the `ApplicationUri` values for **all** servers in the redundant set, including the local server. The OPC UA spec requires this to contain the full set. These are namespace URIs like `urn:ZB:LmxOpcUa`, not endpoint URLs.
|
||||
- `ServiceLevelBase` is the starting ServiceLevel when the server is fully healthy. Degraded conditions subtract from this value.
|
||||
- `Enabled` defaults to `false`
|
||||
- `Mode` supports `Warm` and `Hot` in Phase 1
|
||||
- `Role` supports `Primary` and `Secondary`
|
||||
- `ServerUris` must contain the local `OpcUa.ApplicationUri` when redundancy is enabled
|
||||
- `ServerUris` should contain at least two unique entries when redundancy is enabled
|
||||
- `ServiceLevelBase` should be in the range `1-255`
|
||||
- Effective baseline:
|
||||
- Primary: `ServiceLevelBase`
|
||||
- Secondary: `max(0, ServiceLevelBase - 50)`
|
||||
|
||||
### App root updates
|
||||
|
||||
@@ -117,27 +163,48 @@ public class RedundancyConfiguration
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add RedundancyConfiguration model and bind it
|
||||
### Step 1: Separate application identity from namespace identity
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/.../Configuration/OpcUaConfiguration.cs`
|
||||
- `src/.../OpcUa/OpcUaServerHost.cs`
|
||||
- `docs/OpcUaServer.md`
|
||||
- `tests/.../Configuration/ConfigurationLoadingTests.cs`
|
||||
|
||||
Changes:
|
||||
|
||||
1. Add optional `OpcUa.ApplicationUri`
|
||||
2. Keep `urn:{GalaxyName}:LmxOpcUa` as the namespace URI used by `LmxNodeManager`
|
||||
3. Set `ApplicationConfiguration.ApplicationUri` from `OpcUa.ApplicationUri` when supplied
|
||||
4. Keep `ApplicationUri` and namespace URI distinct in docs and tests
|
||||
|
||||
This step is required before redundancy can be correct.
|
||||
|
||||
### Step 2: Add `RedundancyConfiguration` and bind it
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/.../Configuration/RedundancyConfiguration.cs` (new)
|
||||
- `src/.../Configuration/AppConfiguration.cs`
|
||||
- `src/.../OpcUaService.cs`
|
||||
|
||||
Changes:
|
||||
1. Create `RedundancyConfiguration` class with properties above
|
||||
2. Add `Redundancy` property to `AppConfiguration`
|
||||
|
||||
1. Create `RedundancyConfiguration`
|
||||
2. Add `Redundancy` to `AppConfiguration`
|
||||
3. Bind `configuration.GetSection("Redundancy").Bind(_config.Redundancy);`
|
||||
4. Pass `_config.Redundancy` through to `OpcUaServerHost` and `LmxOpcUaServer`
|
||||
|
||||
### Step 2: Add RedundancyModeResolver
|
||||
### Step 3: Add `RedundancyModeResolver`
|
||||
|
||||
**File:** `src/.../OpcUa/RedundancyModeResolver.cs` (new)
|
||||
|
||||
Responsibilities:
|
||||
- Map `Mode` string to `RedundancySupport` enum value
|
||||
- Validate against supported Phase 1 modes (`Warm`, `Hot`)
|
||||
- Fall back to `None` with warning for unknown modes
|
||||
|
||||
- map `Mode` to `RedundancySupport`
|
||||
- validate supported Phase 1 modes
|
||||
- fall back safely when disabled or invalid
|
||||
|
||||
```csharp
|
||||
public static class RedundancyModeResolver
|
||||
@@ -146,169 +213,199 @@ public static class RedundancyModeResolver
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Add ServiceLevelCalculator
|
||||
### Step 4: Add `ServiceLevelCalculator`
|
||||
|
||||
**File:** `src/.../OpcUa/ServiceLevelCalculator.cs` (new)
|
||||
|
||||
Computes the dynamic `ServiceLevel` byte from runtime health:
|
||||
Purpose:
|
||||
|
||||
- compute the current `ServiceLevel` from a baseline plus health inputs
|
||||
|
||||
Suggested signature:
|
||||
|
||||
```csharp
|
||||
public class ServiceLevelCalculator
|
||||
public sealed class ServiceLevelCalculator
|
||||
{
|
||||
public byte Calculate(int baseLine, bool mxAccessConnected, bool dbConnected, bool isPrimary);
|
||||
public byte Calculate(int baseLevel, bool mxAccessConnected, bool dbConnected);
|
||||
}
|
||||
```
|
||||
|
||||
Logic:
|
||||
- Start with `baseLine` (from config, e.g., 200 for Primary, 150 for Secondary)
|
||||
- Subtract 100 if MXAccess is disconnected
|
||||
- Subtract 50 if Galaxy DB is unreachable
|
||||
- Clamp to 0–255
|
||||
- Return 0 if both MXAccess and DB are down
|
||||
Suggested logic:
|
||||
|
||||
### Step 4: Extend ConfigurationValidator for redundancy
|
||||
- start with the role-adjusted baseline supplied by the caller
|
||||
- subtract 100 if MXAccess is disconnected
|
||||
- subtract 50 if the Galaxy DB is unreachable
|
||||
- return `0` if both are down
|
||||
- clamp to `0-255`
|
||||
|
||||
### Step 5: Extend `ConfigurationValidator`
|
||||
|
||||
**File:** `src/.../Configuration/ConfigurationValidator.cs`
|
||||
|
||||
Add validation/logging for:
|
||||
- `Redundancy.Enabled`, `Mode`, `Role`
|
||||
- `ServerUris` should not be empty when `Enabled = true`
|
||||
- `ServiceLevelBase` should be 1–255
|
||||
- Warning when `Enabled = true` but `ServerUris` has fewer than 2 entries
|
||||
- Log effective redundancy configuration at startup
|
||||
|
||||
### Step 5: Update LmxOpcUaServer to expose redundancy state
|
||||
- `OpcUa.ApplicationUri`
|
||||
- `Redundancy.Enabled`, `Mode`, `Role`
|
||||
- `ServerUris` membership and uniqueness
|
||||
- `ServiceLevelBase`
|
||||
- local `OpcUa.ApplicationUri` must appear in `Redundancy.ServerUris` when enabled
|
||||
- warning when fewer than 2 unique server URIs are configured
|
||||
|
||||
### Step 6: Expose redundancy through the standard OPC UA server object
|
||||
|
||||
**File:** `src/.../OpcUa/LmxOpcUaServer.cs`
|
||||
|
||||
Changes:
|
||||
1. Accept `RedundancyConfiguration` in the constructor
|
||||
2. Override `OnServerStarted` to write redundancy nodes:
|
||||
- Set `Server/ServerRedundancy/RedundancySupport` to the resolved mode
|
||||
- Set `Server/ServerRedundancy/ServerUriArray` to the configured URIs
|
||||
3. Override `SetServerState` or use a timer to update `Server/ServiceLevel` periodically based on `ServiceLevelCalculator`
|
||||
4. Expose a method `UpdateServiceLevel(bool mxConnected, bool dbConnected)` that the service layer can call when health state changes
|
||||
|
||||
### Step 6: Update OpcUaServerHost to pass redundancy config
|
||||
1. Accept `RedundancyConfiguration` and local `ApplicationUri`
|
||||
2. On startup, locate the built-in `ServerObjectState`
|
||||
3. Configure `ServerObjectState.ServiceLevel`
|
||||
4. Configure the server redundancy object using the SDK's standard server-state types instead of writing guessed node ids directly
|
||||
5. If the default `ServerRedundancyState` does not expose `ServerUriArray`, replace or upgrade it with the appropriate non-transparent redundancy state type from the SDK before populating values
|
||||
6. Expose an internal method such as `UpdateServiceLevel(bool mxConnected, bool dbConnected)` for service-layer health updates
|
||||
|
||||
Important: the implementation should use SDK types/constants (`ServerObjectState`, `ServerRedundancyState`, `NonTransparentRedundancyState`, `VariableIds.*`) rather than hand-maintained numeric literals.
|
||||
|
||||
### Step 7: Update `OpcUaServerHost`
|
||||
|
||||
**File:** `src/.../OpcUa/OpcUaServerHost.cs`
|
||||
|
||||
Changes:
|
||||
1. Accept `RedundancyConfiguration` in the constructor
|
||||
2. Pass it through to `LmxOpcUaServer`
|
||||
3. Log active redundancy mode at startup
|
||||
|
||||
### Step 7: Wire ServiceLevel updates in OpcUaService
|
||||
1. Accept `RedundancyConfiguration`
|
||||
2. Pass redundancy config and resolved local `ApplicationUri` into `LmxOpcUaServer`
|
||||
3. Log redundancy mode/role/server URIs at startup
|
||||
|
||||
### Step 8: Wire health updates in `OpcUaService`
|
||||
|
||||
**File:** `src/.../OpcUaService.cs`
|
||||
|
||||
Changes:
|
||||
1. Bind redundancy config section
|
||||
2. Pass redundancy config to `OpcUaServerHost`
|
||||
3. Subscribe to `MxAccessClient.ConnectionStateChanged` to trigger `ServiceLevel` updates
|
||||
4. After Galaxy DB health checks, trigger `ServiceLevel` updates
|
||||
5. Use a periodic timer (e.g., every 5 seconds) to refresh `ServiceLevel` based on current component health
|
||||
|
||||
### Step 8: Update appsettings.json
|
||||
1. Bind and pass redundancy config
|
||||
2. After startup, initialize the starting `ServiceLevel`
|
||||
3. Subscribe to `IMxAccessClient.ConnectionStateChanged`
|
||||
4. Update DB health whenever startup repository checks, change-detection work, or rebuild attempts succeed/fail
|
||||
5. Prefer event-driven updates; add a lightweight periodic refresh only if necessary
|
||||
|
||||
Avoid introducing a second large standalone polling loop when existing connection and repository activity already gives most of the needed health signals.
|
||||
|
||||
### Step 9: Update test builders and helpers before integration coverage
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/.../OpcUaServiceBuilder.cs`
|
||||
- `tests/.../Helpers/OpcUaServerFixture.cs`
|
||||
- `tests/.../Helpers/OpcUaTestClient.cs`
|
||||
|
||||
Changes:
|
||||
|
||||
- add `WithRedundancy(...)`
|
||||
- add `WithApplicationUri(...)` or allow full `OpcUaConfiguration` override
|
||||
- ensure two in-process redundancy tests can run with distinct `ServerName`, `ApplicationUri`, and certificate identity
|
||||
- when needed, use separate PKI roots in tests so paired fixtures do not collide on certificate state
|
||||
|
||||
### Step 10: Update `appsettings.json`
|
||||
|
||||
**File:** `src/.../appsettings.json`
|
||||
|
||||
Add the `Redundancy` section with backward-compatible defaults (`Enabled: false`).
|
||||
Add:
|
||||
|
||||
### Step 9: Update OpcUaServiceBuilder for test injection
|
||||
- `OpcUa.ApplicationUri` example/commentary in docs
|
||||
- `Redundancy` section with `Enabled = false` defaults
|
||||
|
||||
**File:** `src/.../OpcUaServiceBuilder.cs`
|
||||
|
||||
Add `WithRedundancy(RedundancyConfiguration)` builder method so tests can inject redundancy configuration.
|
||||
|
||||
### Step 10: Add CLI `redundancy` command
|
||||
### Step 11: Add CLI `redundancy` command
|
||||
|
||||
**Files:**
|
||||
|
||||
- `tools/opcuacli-dotnet/Commands/RedundancyCommand.cs` (new)
|
||||
- `tools/opcuacli-dotnet/README.md`
|
||||
- `docs/CliTool.md`
|
||||
|
||||
Command: `redundancy`
|
||||
|
||||
Reads from the target server:
|
||||
- `Server/ServerRedundancy/RedundancySupport` (i=11314)
|
||||
- `Server/ServiceLevel` (i=2267)
|
||||
- `Server/ServerRedundancy/ServerUriArray` (i=11492, if non-transparent redundancy)
|
||||
Read:
|
||||
|
||||
Output format:
|
||||
```
|
||||
- `VariableIds.Server_ServerRedundancy_RedundancySupport`
|
||||
- `VariableIds.Server_ServiceLevel`
|
||||
- `VariableIds.Server_ServerRedundancy_ServerUriArray`
|
||||
|
||||
Output example:
|
||||
|
||||
```text
|
||||
Redundancy Mode: Warm
|
||||
Service Level: 200
|
||||
Server URIs:
|
||||
- urn:ZB:LmxOpcUa
|
||||
- urn:ZB:LmxOpcUa2
|
||||
- urn:localhost:LmxOpcUa:instance1
|
||||
- urn:localhost:LmxOpcUa:instance2
|
||||
```
|
||||
|
||||
Options: `--url`, `--username`, `--password`, `--security` (same shared options as other commands).
|
||||
Use SDK constants instead of hardcoded numeric ids in the command implementation.
|
||||
|
||||
### Step 11: Deploy second service instance
|
||||
### Step 12: Deploy the second service instance
|
||||
|
||||
**Deployment target:** `C:\publish\lmxopcua\instance2`
|
||||
|
||||
Configuration differences from instance1:
|
||||
Suggested configuration differences:
|
||||
|
||||
| Setting | instance1 | instance2 |
|
||||
|---|---|---|
|
||||
| `OpcUa.Port` | `4840` | `4841` |
|
||||
| `Dashboard.Port` | `8081` | `8082` |
|
||||
| `OpcUa.ServerName` | `LmxOpcUa` | `LmxOpcUa2` |
|
||||
| `Dashboard.Port` | `8083` | `8084` |
|
||||
| `OpcUa.ApplicationUri` | `urn:localhost:LmxOpcUa:instance1` | `urn:localhost:LmxOpcUa:instance2` |
|
||||
| `Redundancy.Enabled` | `true` | `true` |
|
||||
| `Redundancy.Role` | `Primary` | `Secondary` |
|
||||
| `Redundancy.Mode` | `Warm` | `Warm` |
|
||||
| `Redundancy.ServerUris` | `["urn:ZB:LmxOpcUa", "urn:ZB:LmxOpcUa2"]` | `["urn:ZB:LmxOpcUa", "urn:ZB:LmxOpcUa2"]` |
|
||||
| `Redundancy.ServiceLevelBase` | `200` | `200` |
|
||||
| `Redundancy.ServerUris` | same two-entry set | same two-entry set |
|
||||
|
||||
Windows service for instance2:
|
||||
- Name: `LmxOpcUa2`
|
||||
- Display name: `LMX OPC UA Server (Instance 2)`
|
||||
- Executable: `C:\publish\lmxopcua\instance2\ZB.MOM.WW.LmxOpcUa.Host.exe`
|
||||
Deployment notes:
|
||||
|
||||
Both instances share the same Galaxy DB (`ZB`) and MXAccess runtime. The `GalaxyName` remains `ZB` for both so they expose the same namespace.
|
||||
- both instances should share the same `GalaxyName` and namespace URI
|
||||
- each instance must have a distinct application certificate identity
|
||||
- if certificate handling is sensitive, give each instance an explicit `Security.CertificateSubject` or separate PKI root
|
||||
|
||||
Update `service_info.md` with the second instance details.
|
||||
Update [service_info.md](C:\Users\dohertj2\Desktop\lmxopcua\service_info.md) with the second instance details after deployment is real, not speculative.
|
||||
|
||||
---
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Unit tests — RedundancyModeResolver
|
||||
### Unit tests: `RedundancyModeResolver`
|
||||
|
||||
**New file:** `tests/ZB.MOM.WW.LmxOpcUa.Tests/Redundancy/RedundancyModeResolverTests.cs`
|
||||
|
||||
| Test | Description |
|
||||
|---|---|
|
||||
| `Resolve_Disabled_ReturnsNone` | `Enabled=false` always returns `RedundancySupport.None` |
|
||||
| `Resolve_Warm_ReturnsWarm` | `Mode="Warm"` maps to `RedundancySupport.Warm` |
|
||||
| `Resolve_Hot_ReturnsHot` | `Mode="Hot"` maps to `RedundancySupport.Hot` |
|
||||
| `Resolve_Disabled_ReturnsNone` | `Enabled=false` returns `None` |
|
||||
| `Resolve_Warm_ReturnsWarm` | `Mode="Warm"` maps correctly |
|
||||
| `Resolve_Hot_ReturnsHot` | `Mode="Hot"` maps correctly |
|
||||
| `Resolve_Unknown_FallsBackToNone` | Unknown mode falls back safely |
|
||||
| `Resolve_CaseInsensitive` | `"warm"` and `"WARM"` both resolve |
|
||||
| `Resolve_CaseInsensitive` | Case-insensitive parsing works |
|
||||
|
||||
### Unit tests — ServiceLevelCalculator
|
||||
### Unit tests: `ServiceLevelCalculator`
|
||||
|
||||
**New file:** `tests/ZB.MOM.WW.LmxOpcUa.Tests/Redundancy/ServiceLevelCalculatorTests.cs`
|
||||
|
||||
| Test | Description |
|
||||
|---|---|
|
||||
| `FullyHealthy_Primary_ReturnsBase` | All healthy, primary role → `ServiceLevelBase` |
|
||||
| `FullyHealthy_Secondary_ReturnsBaseMinusFifty` | All healthy, secondary role → `ServiceLevelBase - 50` |
|
||||
| `MxAccessDown_ReducesServiceLevel` | MXAccess disconnected subtracts 100 |
|
||||
| `DbDown_ReducesServiceLevel` | DB unreachable subtracts 50 |
|
||||
| `BothDown_ReturnsZero` | MXAccess + DB both down → 0 |
|
||||
| `ClampedTo255` | Base of 255 with healthy → 255 |
|
||||
| `ClampedToZero` | Heavy penalties don't go negative |
|
||||
| `FullyHealthy_Primary_ReturnsBase` | Healthy primary baseline is preserved |
|
||||
| `FullyHealthy_Secondary_ReturnsBaseMinusFifty` | Healthy secondary baseline is lower |
|
||||
| `MxAccessDown_ReducesServiceLevel` | MXAccess failure reduces score |
|
||||
| `DbDown_ReducesServiceLevel` | DB failure reduces score |
|
||||
| `BothDown_ReturnsZero` | Both unavailable returns 0 |
|
||||
| `ClampedTo255` | Upper clamp works |
|
||||
| `ClampedToZero` | Lower clamp works |
|
||||
|
||||
### Unit tests — RedundancyConfiguration defaults
|
||||
### Unit tests: `RedundancyConfiguration`
|
||||
|
||||
**New file:** `tests/ZB.MOM.WW.LmxOpcUa.Tests/Redundancy/RedundancyConfigurationTests.cs`
|
||||
|
||||
| Test | Description |
|
||||
|---|---|
|
||||
| `DefaultConfig_Disabled` | `Enabled` defaults to `false` |
|
||||
| `DefaultConfig_ModeWarm` | `Mode` defaults to `"Warm"` |
|
||||
| `DefaultConfig_RolePrimary` | `Role` defaults to `"Primary"` |
|
||||
| `DefaultConfig_ModeWarm` | `Mode` defaults to `Warm` |
|
||||
| `DefaultConfig_RolePrimary` | `Role` defaults to `Primary` |
|
||||
| `DefaultConfig_EmptyServerUris` | `ServerUris` defaults to empty |
|
||||
| `DefaultConfig_ServiceLevelBase200` | `ServiceLevelBase` defaults to `200` |
|
||||
|
||||
@@ -316,64 +413,67 @@ Update `service_info.md` with the second instance details.
|
||||
|
||||
**File:** `tests/ZB.MOM.WW.LmxOpcUa.Tests/Configuration/ConfigurationLoadingTests.cs`
|
||||
|
||||
Add:
|
||||
- `Redundancy_Section_BindsCorrectly` — verify binding from appsettings.json
|
||||
- `Redundancy_Section_BindsCustomValues` — in-memory override test
|
||||
- `Validator_RedundancyEnabled_EmptyServerUris_ReturnsTrue_WithWarning` — validates but warns
|
||||
- `Validator_RedundancyEnabled_InvalidServiceLevelBase_ReturnsFalse` — rejects 0 or >255
|
||||
Add coverage for:
|
||||
|
||||
### Integration tests — redundancy E2E
|
||||
- `OpcUa.ApplicationUri`
|
||||
- `Redundancy` section binding
|
||||
- redundancy validation when `ApplicationUri` is missing
|
||||
- redundancy validation when local `ApplicationUri` is absent from `ServerUris`
|
||||
- invalid `ServiceLevelBase`
|
||||
|
||||
### Integration tests
|
||||
|
||||
**New file:** `tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/RedundancyTests.cs`
|
||||
|
||||
These tests start two in-process OPC UA servers with redundancy enabled and verify client-visible behavior:
|
||||
Cover:
|
||||
|
||||
| Test | Description |
|
||||
|---|---|
|
||||
| `Server_WithRedundancyDisabled_ReportsNone` | Default config → `RedundancySupport.None`, `ServiceLevel=255` |
|
||||
| `Server_WithRedundancyEnabled_ReportsConfiguredMode` | `Enabled=true, Mode=Warm` → `RedundancySupport.Warm` |
|
||||
| `Server_WithRedundancyEnabled_ExposesServerUriArray` | Client can read `ServerUriArray` and it matches config |
|
||||
| `Server_Primary_HasHigherServiceLevel_ThanSecondary` | Primary server reports higher `ServiceLevel` than secondary |
|
||||
| `TwoServers_BothExposeSameRedundantSet` | Two server fixtures, both report the same `ServerUriArray` |
|
||||
| `Server_ServiceLevel_DropsWith_MxAccessDisconnect` | Simulate MXAccess disconnect → `ServiceLevel` decreases |
|
||||
- redundancy disabled reports `None`
|
||||
- warm redundancy reports configured mode
|
||||
- `ServerUriArray` matches configuration
|
||||
- primary reports higher `ServiceLevel` than secondary
|
||||
- both servers expose the same namespace URI but different `ApplicationUri` values
|
||||
- service level drops when MXAccess disconnects
|
||||
|
||||
Pattern: Use `OpcUaServerFixture.WithFakeMxAccessClient()` with redundancy config injected, connect with `OpcUaTestClient`, read the standard OPC UA redundancy nodes.
|
||||
Pattern:
|
||||
|
||||
- use two fixture instances
|
||||
- give each fixture a distinct `ServerName`, `ApplicationUri`, and port
|
||||
- if secure transport is enabled in those tests, isolate PKI roots to avoid certificate cross-talk
|
||||
|
||||
---
|
||||
|
||||
## Documentation Plan
|
||||
|
||||
### New file: `docs/Redundancy.md`
|
||||
### New file
|
||||
|
||||
- `docs/Redundancy.md`
|
||||
|
||||
Contents:
|
||||
1. Overview of OPC UA non-transparent redundancy
|
||||
2. Redundancy configuration section reference (`Enabled`, `Mode`, `Role`, `ServerUris`, `ServiceLevelBase`)
|
||||
3. ServiceLevel computation logic and degraded-state penalties
|
||||
4. How clients discover and fail over between instances
|
||||
5. Deployment guide for a two-instance redundant pair (ports, service names, shared Galaxy DB)
|
||||
|
||||
1. overview of OPC UA non-transparent redundancy
|
||||
2. difference between namespace URI and server `ApplicationUri`
|
||||
3. redundancy configuration reference
|
||||
4. service-level computation
|
||||
5. two-instance deployment guide
|
||||
6. CLI `redundancy` command usage
|
||||
7. Troubleshooting: mismatched `ServerUris`, ServiceLevel stuck at 0, etc.
|
||||
7. troubleshooting
|
||||
|
||||
### Updates to existing docs
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `docs/Configuration.md` | Add `Redundancy` section table, example JSON, add to validation rules list, update example appsettings.json |
|
||||
| `docs/OpcUaServer.md` | Add redundancy state exposure section, link to `Redundancy.md` |
|
||||
| `docs/CliTool.md` | Add `redundancy` command documentation |
|
||||
| `docs/Configuration.md` | Add `OpcUa.ApplicationUri` and `Redundancy` sections |
|
||||
| `docs/OpcUaServer.md` | Correct the current `ApplicationUri == namespace` description and add redundancy behavior |
|
||||
| `docs/CliTool.md` | Add `redundancy` command |
|
||||
| `docs/ServiceHosting.md` | Add multi-instance deployment notes |
|
||||
| `README.md` | Add `Redundancy` to the component documentation table, mention redundancy in Quick Start |
|
||||
| `README.md` | Mention redundancy support and link docs |
|
||||
| `CLAUDE.md` | Add redundancy architecture note |
|
||||
|
||||
### Update: `service_info.md`
|
||||
### Update after real deployment
|
||||
|
||||
Add a second section documenting `instance2`:
|
||||
- Path: `C:\publish\lmxopcua\instance2`
|
||||
- Windows service name: `LmxOpcUa2`
|
||||
- Port: `4841`
|
||||
- Dashboard port: `8084`
|
||||
- Redundancy role: `Secondary`
|
||||
- Endpoint: `opc.tcp://localhost:4841/LmxOpcUa`
|
||||
- `service_info.md`
|
||||
|
||||
Only update this once the second instance is actually deployed and verified.
|
||||
|
||||
---
|
||||
|
||||
@@ -381,128 +481,117 @@ Add a second section documenting `instance2`:
|
||||
|
||||
| File | Action | Description |
|
||||
|---|---|---|
|
||||
| `src/.../Configuration/OpcUaConfiguration.cs` | Modify | Add explicit `ApplicationUri` |
|
||||
| `src/.../Configuration/RedundancyConfiguration.cs` | New | Redundancy config model |
|
||||
| `src/.../Configuration/AppConfiguration.cs` | Modify | Add `Redundancy` section |
|
||||
| `src/.../Configuration/ConfigurationValidator.cs` | Modify | Validate/log redundancy settings |
|
||||
| `src/.../OpcUa/RedundancyModeResolver.cs` | New | Mode string → `RedundancySupport` enum |
|
||||
| `src/.../OpcUa/ServiceLevelCalculator.cs` | New | Dynamic ServiceLevel from health state |
|
||||
| `src/.../OpcUa/LmxOpcUaServer.cs` | Modify | Expose redundancy nodes, accept ServiceLevel updates |
|
||||
| `src/.../OpcUa/OpcUaServerHost.cs` | Modify | Pass redundancy config through |
|
||||
| `src/.../OpcUaService.cs` | Modify | Bind redundancy config, wire ServiceLevel updates |
|
||||
| `src/.../OpcUaServiceBuilder.cs` | Modify | Add `WithRedundancy()` builder |
|
||||
| `src/.../appsettings.json` | Modify | Add `Redundancy` section |
|
||||
| `tools/opcuacli-dotnet/Commands/RedundancyCommand.cs` | New | CLI command to read redundancy info |
|
||||
| `tests/.../Redundancy/RedundancyModeResolverTests.cs` | New | Mode resolver unit tests |
|
||||
| `tests/.../Redundancy/ServiceLevelCalculatorTests.cs` | New | ServiceLevel computation tests |
|
||||
| `tests/.../Redundancy/RedundancyConfigurationTests.cs` | New | Config defaults tests |
|
||||
| `tests/.../Configuration/ConfigurationLoadingTests.cs` | Modify | Binding + validation tests |
|
||||
| `tests/.../Integration/RedundancyTests.cs` | New | E2E two-server redundancy tests |
|
||||
| `tests/.../Helpers/OpcUaServerFixture.cs` | Modify | Accept redundancy config |
|
||||
| `docs/Redundancy.md` | New | Dedicated redundancy component doc |
|
||||
| `docs/Configuration.md` | Modify | Add Redundancy section |
|
||||
| `docs/OpcUaServer.md` | Modify | Add redundancy state section |
|
||||
| `docs/CliTool.md` | Modify | Add redundancy command |
|
||||
| `docs/ServiceHosting.md` | Modify | Multi-instance notes |
|
||||
| `README.md` | Modify | Add Redundancy to component table |
|
||||
| `CLAUDE.md` | Modify | Add redundancy architecture note |
|
||||
| `service_info.md` | Modify | Add instance2 details |
|
||||
| `src/.../Configuration/ConfigurationValidator.cs` | Modify | Validate/log redundancy and application identity |
|
||||
| `src/.../OpcUa/RedundancyModeResolver.cs` | New | Map config mode to `RedundancySupport` |
|
||||
| `src/.../OpcUa/ServiceLevelCalculator.cs` | New | Compute `ServiceLevel` from health inputs |
|
||||
| `src/.../OpcUa/LmxOpcUaServer.cs` | Modify | Expose redundancy state via SDK server object |
|
||||
| `src/.../OpcUa/OpcUaServerHost.cs` | Modify | Pass local application identity and redundancy config |
|
||||
| `src/.../OpcUaService.cs` | Modify | Bind config and wire health updates |
|
||||
| `src/.../OpcUaServiceBuilder.cs` | Modify | Support redundancy/application identity injection |
|
||||
| `src/.../appsettings.json` | Modify | Add redundancy settings |
|
||||
| `tools/opcuacli-dotnet/Commands/RedundancyCommand.cs` | New | Read redundancy state from a server |
|
||||
| `tests/.../Redundancy/*.cs` | New | Unit tests for redundancy config and calculators |
|
||||
| `tests/.../Configuration/ConfigurationLoadingTests.cs` | Modify | Bind/validate new settings |
|
||||
| `tests/.../Integration/RedundancyTests.cs` | New | Paired-server integration tests |
|
||||
| `tests/.../Helpers/OpcUaServerFixture.cs` | Modify | Support paired redundancy fixtures |
|
||||
| `tests/.../Helpers/OpcUaTestClient.cs` | Modify | Read redundancy nodes in integration tests |
|
||||
| `docs/Redundancy.md` | New | Dedicated redundancy guide |
|
||||
| `docs/Configuration.md` | Modify | Document new config |
|
||||
| `docs/OpcUaServer.md` | Modify | Correct application identity and add redundancy details |
|
||||
| `docs/CliTool.md` | Modify | Document `redundancy` command |
|
||||
| `docs/ServiceHosting.md` | Modify | Multi-instance deployment notes |
|
||||
| `README.md` | Modify | Link redundancy docs |
|
||||
| `CLAUDE.md` | Modify | Architecture note |
|
||||
| `service_info.md` | Modify later | Document real second-instance deployment |
|
||||
|
||||
---
|
||||
|
||||
## Verification Guardrails
|
||||
|
||||
Each step must pass these gates before proceeding to the next:
|
||||
### Gate 1: Build
|
||||
|
||||
### Gate 1: Build (after each implementation step)
|
||||
```bash
|
||||
dotnet build ZB.MOM.WW.LmxOpcUa.slnx
|
||||
```
|
||||
Must produce 0 errors. Proceed only when green.
|
||||
|
||||
### Gate 2: Unit tests (after steps 1–4, 9)
|
||||
### Gate 2: Unit tests
|
||||
|
||||
```bash
|
||||
dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests
|
||||
```
|
||||
All existing + new tests must pass. No regressions.
|
||||
|
||||
### Gate 3: Integration tests (after steps 5–7)
|
||||
### Gate 3: Redundancy integration tests
|
||||
|
||||
```bash
|
||||
dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests --filter "FullyQualifiedName~Integration.RedundancyTests"
|
||||
dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests --filter "FullyQualifiedName~Redundancy"
|
||||
```
|
||||
All redundancy E2E tests must pass.
|
||||
|
||||
### Gate 4: CLI tool builds (after step 10)
|
||||
### Gate 4: CLI build
|
||||
|
||||
```bash
|
||||
cd tools/opcuacli-dotnet && dotnet build
|
||||
cd tools/opcuacli-dotnet
|
||||
dotnet build
|
||||
```
|
||||
Must compile without errors.
|
||||
|
||||
### Gate 5: Manual verification — single instance (after step 8)
|
||||
### Gate 5: Manual single-instance check
|
||||
|
||||
```bash
|
||||
# Publish and start with Redundancy.Enabled=false
|
||||
opcuacli-dotnet.exe connect -u opc.tcp://localhost:4840/LmxOpcUa
|
||||
opcuacli-dotnet.exe redundancy -u opc.tcp://localhost:4840/LmxOpcUa
|
||||
# Should report: RedundancySupport=None, ServiceLevel=255
|
||||
```
|
||||
|
||||
### Gate 6: Manual verification — redundant pair (after step 11)
|
||||
Expected:
|
||||
|
||||
- `RedundancySupport=None`
|
||||
- `ServiceLevel=255`
|
||||
|
||||
### Gate 6: Manual paired-instance check
|
||||
|
||||
```bash
|
||||
# Start both instances
|
||||
sc start LmxOpcUa
|
||||
sc start LmxOpcUa2
|
||||
|
||||
# Verify instance1 (Primary)
|
||||
opcuacli-dotnet.exe redundancy -u opc.tcp://localhost:4840/LmxOpcUa
|
||||
# Should report: RedundancySupport=Warm, ServiceLevel=200, ServerUris=[urn:ZB:LmxOpcUa, urn:ZB:LmxOpcUa2]
|
||||
|
||||
# Verify instance2 (Secondary)
|
||||
opcuacli-dotnet.exe redundancy -u opc.tcp://localhost:4841/LmxOpcUa
|
||||
# Should report: RedundancySupport=Warm, ServiceLevel=150, ServerUris=[urn:ZB:LmxOpcUa, urn:ZB:LmxOpcUa2]
|
||||
|
||||
# Both instances should serve the same Galaxy address space
|
||||
opcuacli-dotnet.exe browse -u opc.tcp://localhost:4840/LmxOpcUa -r -d 2
|
||||
opcuacli-dotnet.exe browse -u opc.tcp://localhost:4841/LmxOpcUa -r -d 2
|
||||
```
|
||||
|
||||
### Gate 7: Full test suite (final)
|
||||
Expected:
|
||||
|
||||
- both report the same `ServerUriArray`
|
||||
- each reports its own unique local `ApplicationUri`
|
||||
- primary reports a higher `ServiceLevel`
|
||||
|
||||
### Gate 7: Full test suite
|
||||
|
||||
```bash
|
||||
dotnet test ZB.MOM.WW.LmxOpcUa.slnx
|
||||
```
|
||||
All tests across all projects must pass.
|
||||
|
||||
### Gate 8: Documentation review
|
||||
- All new/modified doc files render correctly in Markdown
|
||||
- Example JSON snippets match the actual `appsettings.json`
|
||||
- CLI examples use correct flags and expected output
|
||||
- `service_info.md` accurately reflects both deployed instances
|
||||
|
||||
---
|
||||
|
||||
## Risks and Considerations
|
||||
|
||||
1. **Backward compatibility**: `Redundancy.Enabled = false` must be the default so existing single-instance deployments are unaffected.
|
||||
2. **ServiceLevel timing**: Updates must not race with OPC UA publish cycles. Use the server's internal lock or `ServerInternal` APIs.
|
||||
3. **ServerUriArray immutability**: The OPC UA spec expects this to be static during a server session. Changes require a server restart.
|
||||
4. **MXAccess shared state**: Both instances connect to the same MXAccess runtime. If MXAccess has per-client registration limits, verify that two clients can coexist.
|
||||
5. **Galaxy DB contention**: Both instances poll for deploy changes. Ensure change detection doesn't trigger duplicate rebuilds or locking issues.
|
||||
6. **Port conflicts**: The second instance must use different ports for OPC UA (4841) and Dashboard (8084).
|
||||
7. **Certificate identity**: Each instance needs its own application certificate with a distinct `SubjectName` matching its `ServerName`.
|
||||
1. **Application identity is the main correctness risk.** Without unique `ApplicationUri` values, the redundant set is invalid even if `ServerUriArray` is populated.
|
||||
2. **SDK wiring may require replacing the default redundancy state node.** The base `ServerRedundancyState` does not expose `ServerUriArray`; the implementation may need the non-transparent subtype from the SDK.
|
||||
3. **Two in-process servers can collide on certificates.** Tests and deployment need distinct application identities and, when necessary, isolated PKI roots.
|
||||
4. **Both instances hit the same MXAccess runtime and Galaxy DB.** Verify client-registration and polling behavior under paired load.
|
||||
5. **`ServiceLevel` should remain meaningful, not noisy.** Prefer deterministic role + health inputs over frequent arbitrary adjustments.
|
||||
6. **`service_info.md` is deployment documentation, not design.** Do not prefill it with speculative values before the second instance actually exists.
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Steps 1–4: Config model, resolver, calculator, validator (unit-testable in isolation)
|
||||
2. **Gate 1 + Gate 2**: Build + unit tests pass
|
||||
3. Steps 5–7: Server integration (redundancy nodes, ServiceLevel wiring)
|
||||
4. **Gate 1 + Gate 2 + Gate 3**: Build + all tests including E2E
|
||||
5. Step 8: Update appsettings.json
|
||||
6. **Gate 5**: Manual single-instance verification
|
||||
7. Step 9: Update service builder for tests
|
||||
8. Step 10: CLI redundancy command
|
||||
9. **Gate 4**: CLI builds
|
||||
10. Step 11: Deploy second instance + update service_info.md
|
||||
11. **Gate 6**: Manual two-instance verification
|
||||
12. Documentation updates (all doc files)
|
||||
13. **Gate 7 + Gate 8**: Full test suite + documentation review
|
||||
14. Commit and push
|
||||
1. Step 1: add `OpcUa.ApplicationUri` and separate it from namespace identity
|
||||
2. Steps 2-5: config model, resolver, calculator, validator
|
||||
3. Gate 1 + Gate 2
|
||||
4. Step 9: update builders/helpers so tests can express paired servers cleanly
|
||||
5. Step 6-8: server exposure and service-layer health wiring
|
||||
6. Gate 1 + Gate 2 + Gate 3
|
||||
7. Step 10: update `appsettings.json`
|
||||
8. Step 11: add CLI `redundancy` command
|
||||
9. Gate 4 + Gate 5
|
||||
10. Step 12: deploy and verify the second instance
|
||||
11. Update `service_info.md` with real deployment details
|
||||
12. Documentation updates
|
||||
13. Gate 7
|
||||
|
||||
@@ -46,6 +46,49 @@ Observed results:
|
||||
- `read ns=1;s=MESReceiver_001.MoveInPartNumbers`: succeeded with good status `0x00000000`.
|
||||
- `read ns=1;s=MESReceiver_001.MoveInPartNumbers[]`: failed with `BadNodeIdUnknown` (`0x80340000`).
|
||||
|
||||
---
|
||||
|
||||
## Instance 2 (Redundant Secondary)
|
||||
|
||||
Deployed: `2026-03-28`
|
||||
|
||||
Deployment path: `C:\publish\lmxopcua\instance2`
|
||||
|
||||
Configuration:
|
||||
- `OpcUa.Port`: `4841`
|
||||
- `OpcUa.ServerName`: `LmxOpcUa2`
|
||||
- `OpcUa.ApplicationUri`: `urn:localhost:LmxOpcUa:instance2`
|
||||
- `Dashboard.Port`: `8082`
|
||||
- `MxAccess.ClientName`: `LmxOpcUa2`
|
||||
- `Redundancy.Enabled`: `true`
|
||||
- `Redundancy.Mode`: `Warm`
|
||||
- `Redundancy.Role`: `Secondary`
|
||||
- `Redundancy.ServerUris`: `["urn:localhost:LmxOpcUa:instance1", "urn:localhost:LmxOpcUa:instance2"]`
|
||||
|
||||
Windows service:
|
||||
- Name: `LmxOpcUa2`
|
||||
- Display name: `LMX OPC UA Server (Instance 2)`
|
||||
- Account: `LocalSystem`
|
||||
- Endpoint: `opc.tcp://localhost:4841/LmxOpcUa`
|
||||
|
||||
Instance 1 redundancy update (same date):
|
||||
- `OpcUa.ApplicationUri`: `urn:localhost:LmxOpcUa:instance1`
|
||||
- `Redundancy.Enabled`: `true`
|
||||
- `Redundancy.Mode`: `Warm`
|
||||
- `Redundancy.Role`: `Primary`
|
||||
- `Redundancy.ServerUris`: `["urn:localhost:LmxOpcUa:instance1", "urn:localhost:LmxOpcUa:instance2"]`
|
||||
|
||||
CLI verification:
|
||||
```
|
||||
opcuacli-dotnet.exe redundancy -u opc.tcp://localhost:4840/LmxOpcUa
|
||||
→ Redundancy Mode: Warm, Service Level: 200, Application URI: urn:localhost:LmxOpcUa:instance1
|
||||
|
||||
opcuacli-dotnet.exe redundancy -u opc.tcp://localhost:4841/LmxOpcUa
|
||||
→ Redundancy Mode: Warm, Service Level: 150, Application URI: urn:localhost:LmxOpcUa:instance2
|
||||
```
|
||||
|
||||
Both instances report the same `ServerUriArray` and expose the same Galaxy namespace (`urn:ZB:LmxOpcUa`).
|
||||
|
||||
## Notes
|
||||
|
||||
The service deployment and restart succeeded. The live CLI checks confirm the endpoint is reachable and that the array node identifier has changed to the bracketless form. The array value on the live service still prints as blank even though the status is good, so if this environment should have populated `MoveInPartNumbers`, the runtime data path still needs follow-up investigation.
|
||||
|
||||
@@ -39,5 +39,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
/// Gets or sets the transport security settings that control which OPC UA security profiles are exposed.
|
||||
/// </summary>
|
||||
public SecurityProfileConfiguration Security { get; set; } = new SecurityProfileConfiguration();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the redundancy settings that control how this server participates in a redundant pair.
|
||||
/// </summary>
|
||||
public RedundancyConfiguration Redundancy { get; set; } = new RedundancyConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,47 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
Log.Warning("Only the 'None' security profile is configured — transport security is disabled");
|
||||
}
|
||||
|
||||
// Redundancy
|
||||
if (config.OpcUa.ApplicationUri != null)
|
||||
Log.Information("OpcUa.ApplicationUri={ApplicationUri}", config.OpcUa.ApplicationUri);
|
||||
|
||||
Log.Information("Redundancy.Enabled={Enabled}, Mode={Mode}, Role={Role}, ServiceLevelBase={ServiceLevelBase}",
|
||||
config.Redundancy.Enabled, config.Redundancy.Mode, config.Redundancy.Role, config.Redundancy.ServiceLevelBase);
|
||||
|
||||
if (config.Redundancy.ServerUris.Count > 0)
|
||||
Log.Information("Redundancy.ServerUris=[{ServerUris}]", string.Join(", ", config.Redundancy.ServerUris));
|
||||
|
||||
if (config.Redundancy.Enabled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config.OpcUa.ApplicationUri))
|
||||
{
|
||||
Log.Error("OpcUa.ApplicationUri must be set when redundancy is enabled — each instance needs a unique identity");
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (config.Redundancy.ServerUris.Count < 2)
|
||||
{
|
||||
Log.Warning("Redundancy.ServerUris contains fewer than 2 entries — a redundant set typically has at least 2 servers");
|
||||
}
|
||||
|
||||
if (config.OpcUa.ApplicationUri != null && !config.Redundancy.ServerUris.Contains(config.OpcUa.ApplicationUri))
|
||||
{
|
||||
Log.Warning("Local OpcUa.ApplicationUri '{ApplicationUri}' is not listed in Redundancy.ServerUris", config.OpcUa.ApplicationUri);
|
||||
}
|
||||
|
||||
var mode = RedundancyModeResolver.Resolve(config.Redundancy.Mode, true);
|
||||
if (mode == Opc.Ua.RedundancySupport.None)
|
||||
{
|
||||
Log.Warning("Redundancy is enabled but Mode '{Mode}' is not recognized — will fall back to None", config.Redundancy.Mode);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.Redundancy.ServiceLevelBase < 1 || config.Redundancy.ServiceLevelBase > 255)
|
||||
{
|
||||
Log.Error("Redundancy.ServiceLevelBase must be between 1 and 255");
|
||||
valid = false;
|
||||
}
|
||||
|
||||
Log.Information("=== Configuration {Status} ===", valid ? "Valid" : "INVALID");
|
||||
return valid;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
/// </summary>
|
||||
public string GalaxyName { get; set; } = "ZB";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the explicit application URI for this server instance.
|
||||
/// When <see langword="null"/>, defaults to <c>urn:{GalaxyName}:LmxOpcUa</c>.
|
||||
/// Must be set to a unique value per instance when redundancy is enabled.
|
||||
/// </summary>
|
||||
public string? ApplicationUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of simultaneous OPC UA sessions accepted by the host.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Non-transparent redundancy settings that control how the server advertises itself
|
||||
/// within a redundant pair and computes its dynamic ServiceLevel.
|
||||
/// </summary>
|
||||
public class RedundancyConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether redundancy is enabled. When <see langword="false"/> (default),
|
||||
/// the server reports <c>RedundancySupport.None</c> and <c>ServiceLevel = 255</c>.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the redundancy mode. Valid values: <c>Warm</c>, <c>Hot</c>.
|
||||
/// </summary>
|
||||
public string Mode { get; set; } = "Warm";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the role of this instance. Valid values: <c>Primary</c>, <c>Secondary</c>.
|
||||
/// The primary advertises a higher ServiceLevel than the secondary when both are healthy.
|
||||
/// </summary>
|
||||
public string Role { get; set; } = "Primary";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ApplicationUri values for all servers in the redundant set.
|
||||
/// Must include this instance's own <c>OpcUa.ApplicationUri</c>.
|
||||
/// </summary>
|
||||
public List<string> ServerUris { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the base ServiceLevel when the server is fully healthy.
|
||||
/// The secondary automatically receives <c>ServiceLevelBase - 50</c>.
|
||||
/// Valid range: 1-255.
|
||||
/// </summary>
|
||||
public int ServiceLevelBase { get; set; } = 200;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Opc.Ua;
|
||||
@@ -11,7 +12,8 @@ using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom OPC UA server that creates the LmxNodeManager and handles user authentication. (OPC-001, OPC-012)
|
||||
/// Custom OPC UA server that creates the LmxNodeManager, handles user authentication,
|
||||
/// and exposes redundancy state through the standard server object. (OPC-001, OPC-012)
|
||||
/// </summary>
|
||||
public class LmxOpcUaServer : StandardServer
|
||||
{
|
||||
@@ -24,6 +26,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
private readonly bool _alarmTrackingEnabled;
|
||||
private readonly AuthenticationConfiguration _authConfig;
|
||||
private readonly IUserAuthenticationProvider? _authProvider;
|
||||
private readonly RedundancyConfiguration _redundancyConfig;
|
||||
private readonly string? _applicationUri;
|
||||
private readonly ServiceLevelCalculator _serviceLevelCalculator = new ServiceLevelCalculator();
|
||||
private LmxNodeManager? _nodeManager;
|
||||
|
||||
/// <summary>
|
||||
@@ -45,7 +50,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
|
||||
public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
|
||||
HistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false,
|
||||
AuthenticationConfiguration? authConfig = null, IUserAuthenticationProvider? authProvider = null)
|
||||
AuthenticationConfiguration? authConfig = null, IUserAuthenticationProvider? authProvider = null,
|
||||
RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null)
|
||||
{
|
||||
_galaxyName = galaxyName;
|
||||
_mxAccessClient = mxAccessClient;
|
||||
@@ -54,6 +60,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
_alarmTrackingEnabled = alarmTrackingEnabled;
|
||||
_authConfig = authConfig ?? new AuthenticationConfiguration();
|
||||
_authProvider = authProvider;
|
||||
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
|
||||
_applicationUri = applicationUri;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -72,6 +80,104 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
{
|
||||
base.OnServerStarted(server);
|
||||
server.SessionManager.ImpersonateUser += OnImpersonateUser;
|
||||
|
||||
ConfigureRedundancy(server);
|
||||
}
|
||||
|
||||
private void ConfigureRedundancy(IServerInternal server)
|
||||
{
|
||||
var mode = RedundancyModeResolver.Resolve(_redundancyConfig.Mode, _redundancyConfig.Enabled);
|
||||
|
||||
try
|
||||
{
|
||||
// Set RedundancySupport via the diagnostics node manager
|
||||
var redundancySupportNodeId = VariableIds.Server_ServerRedundancy_RedundancySupport;
|
||||
var redundancySupportNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
|
||||
redundancySupportNodeId, typeof(BaseVariableState)) as BaseVariableState;
|
||||
|
||||
if (redundancySupportNode != null)
|
||||
{
|
||||
redundancySupportNode.Value = (int)mode;
|
||||
redundancySupportNode.ClearChangeMasks(server.DefaultSystemContext, false);
|
||||
Log.Information("Set RedundancySupport to {Mode}", mode);
|
||||
}
|
||||
|
||||
// Set ServerUriArray for non-transparent redundancy
|
||||
if (_redundancyConfig.Enabled && _redundancyConfig.ServerUris.Count > 0)
|
||||
{
|
||||
var serverUriArrayNodeId = VariableIds.Server_ServerRedundancy_ServerUriArray;
|
||||
var serverUriArrayNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
|
||||
serverUriArrayNodeId, typeof(BaseVariableState)) as BaseVariableState;
|
||||
|
||||
if (serverUriArrayNode != null)
|
||||
{
|
||||
serverUriArrayNode.Value = _redundancyConfig.ServerUris.ToArray();
|
||||
serverUriArrayNode.ClearChangeMasks(server.DefaultSystemContext, false);
|
||||
Log.Information("Set ServerUriArray to [{Uris}]", string.Join(", ", _redundancyConfig.ServerUris));
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("ServerUriArray node not found in address space — SDK may not expose it for RedundancySupport.None base type");
|
||||
}
|
||||
}
|
||||
|
||||
// Set initial ServiceLevel
|
||||
var initialLevel = CalculateCurrentServiceLevel(true, true);
|
||||
SetServiceLevelValue(server, initialLevel);
|
||||
Log.Information("Initial ServiceLevel set to {ServiceLevel}", initialLevel);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to configure redundancy nodes — redundancy state may not be visible to clients");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the server's ServiceLevel based on current runtime health.
|
||||
/// Called by the service layer when MXAccess or DB health changes.
|
||||
/// </summary>
|
||||
/// <param name="mxAccessConnected">Whether the MXAccess connection is healthy.</param>
|
||||
/// <param name="dbConnected">Whether the Galaxy repository database is reachable.</param>
|
||||
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected)
|
||||
{
|
||||
var level = CalculateCurrentServiceLevel(mxAccessConnected, dbConnected);
|
||||
try
|
||||
{
|
||||
if (ServerInternal != null)
|
||||
{
|
||||
SetServiceLevelValue(ServerInternal, level);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug(ex, "Failed to update ServiceLevel node");
|
||||
}
|
||||
}
|
||||
|
||||
private byte CalculateCurrentServiceLevel(bool mxAccessConnected, bool dbConnected)
|
||||
{
|
||||
if (!_redundancyConfig.Enabled)
|
||||
return 255; // SDK default when redundancy is not configured
|
||||
|
||||
var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase);
|
||||
var baseLevel = isPrimary
|
||||
? _redundancyConfig.ServiceLevelBase
|
||||
: Math.Max(0, _redundancyConfig.ServiceLevelBase - 50);
|
||||
|
||||
return _serviceLevelCalculator.Calculate(baseLevel, mxAccessConnected, dbConnected);
|
||||
}
|
||||
|
||||
private static void SetServiceLevelValue(IServerInternal server, byte level)
|
||||
{
|
||||
var serviceLevelNodeId = VariableIds.Server_ServiceLevel;
|
||||
var serviceLevelNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
|
||||
serviceLevelNodeId, typeof(BaseVariableState)) as BaseVariableState;
|
||||
|
||||
if (serviceLevelNode != null)
|
||||
{
|
||||
serviceLevelNode.Value = level;
|
||||
serviceLevelNode.ClearChangeMasks(server.DefaultSystemContext, false);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnImpersonateUser(Session session, ImpersonateEventArgs args)
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
private readonly AuthenticationConfiguration _authConfig;
|
||||
private readonly IUserAuthenticationProvider? _authProvider;
|
||||
private readonly SecurityProfileConfiguration _securityConfig;
|
||||
private readonly RedundancyConfiguration _redundancyConfig;
|
||||
private ApplicationInstance? _application;
|
||||
private LmxOpcUaServer? _server;
|
||||
|
||||
@@ -43,6 +44,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
/// </summary>
|
||||
public bool IsRunning => _server != null;
|
||||
|
||||
/// <summary>
|
||||
/// Updates the OPC UA ServiceLevel based on current runtime health.
|
||||
/// </summary>
|
||||
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected) =>
|
||||
_server?.UpdateServiceLevel(mxAccessConnected, dbConnected);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new host for the Galaxy-backed OPC UA server instance.
|
||||
/// </summary>
|
||||
@@ -54,7 +61,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
HistorianDataSource? historianDataSource = null,
|
||||
AuthenticationConfiguration? authConfig = null,
|
||||
IUserAuthenticationProvider? authProvider = null,
|
||||
SecurityProfileConfiguration? securityConfig = null)
|
||||
SecurityProfileConfiguration? securityConfig = null,
|
||||
RedundancyConfiguration? redundancyConfig = null)
|
||||
{
|
||||
_config = config;
|
||||
_mxAccessClient = mxAccessClient;
|
||||
@@ -63,6 +71,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
_authConfig = authConfig ?? new AuthenticationConfiguration();
|
||||
_authProvider = authProvider;
|
||||
_securityConfig = securityConfig ?? new SecurityProfileConfiguration();
|
||||
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -71,6 +80,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
public async Task StartAsync()
|
||||
{
|
||||
var namespaceUri = $"urn:{_config.GalaxyName}:LmxOpcUa";
|
||||
var applicationUri = _config.ApplicationUri ?? namespaceUri;
|
||||
|
||||
// Resolve configured security profiles
|
||||
var securityPolicies = SecurityProfileResolver.Resolve(_securityConfig.Profiles);
|
||||
@@ -127,7 +137,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
var appConfig = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = _config.ServerName,
|
||||
ApplicationUri = namespaceUri,
|
||||
ApplicationUri = applicationUri,
|
||||
ApplicationType = ApplicationType.Server,
|
||||
ProductUri = namespaceUri,
|
||||
ServerConfiguration = serverConfig,
|
||||
@@ -174,11 +184,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
}
|
||||
|
||||
_server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource,
|
||||
_config.AlarmTrackingEnabled, _authConfig, _authProvider);
|
||||
_config.AlarmTrackingEnabled, _authConfig, _authProvider, _redundancyConfig, applicationUri);
|
||||
await _application.Start(_server);
|
||||
|
||||
Log.Information("OPC UA server started on opc.tcp://{BindAddress}:{Port}{EndpointPath} (namespace={Namespace})",
|
||||
_config.BindAddress, _config.Port, _config.EndpointPath, namespaceUri);
|
||||
Log.Information("OPC UA server started on opc.tcp://{BindAddress}:{Port}{EndpointPath} (applicationUri={ApplicationUri}, namespace={Namespace})",
|
||||
_config.BindAddress, _config.Port, _config.EndpointPath, applicationUri, namespaceUri);
|
||||
}
|
||||
|
||||
private void OnCertificateValidation(CertificateValidator sender, CertificateValidationEventArgs e)
|
||||
|
||||
41
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/RedundancyModeResolver.cs
Normal file
41
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/RedundancyModeResolver.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using Opc.Ua;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a configured redundancy mode string to the OPC UA <see cref="RedundancySupport"/> enum.
|
||||
/// </summary>
|
||||
public static class RedundancyModeResolver
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(RedundancyModeResolver));
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the configured mode string to a <see cref="RedundancySupport"/> value.
|
||||
/// Returns <see cref="RedundancySupport.None"/> when redundancy is disabled or the mode is unrecognized.
|
||||
/// </summary>
|
||||
/// <param name="mode">The mode string from configuration (e.g., "Warm", "Hot").</param>
|
||||
/// <param name="enabled">Whether redundancy is enabled.</param>
|
||||
/// <returns>The resolved redundancy support mode.</returns>
|
||||
public static RedundancySupport Resolve(string mode, bool enabled)
|
||||
{
|
||||
if (!enabled)
|
||||
return RedundancySupport.None;
|
||||
|
||||
var resolved = (mode ?? "").Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"warm" => RedundancySupport.Warm,
|
||||
"hot" => RedundancySupport.Hot,
|
||||
_ => RedundancySupport.None
|
||||
};
|
||||
|
||||
if (resolved == RedundancySupport.None)
|
||||
{
|
||||
Log.Warning("Unknown redundancy mode '{Mode}' — falling back to None. Supported modes: Warm, Hot", mode);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/ServiceLevelCalculator.cs
Normal file
33
src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/ServiceLevelCalculator.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the OPC UA ServiceLevel byte from a baseline and runtime health inputs.
|
||||
/// </summary>
|
||||
public sealed class ServiceLevelCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the current ServiceLevel from a role-adjusted baseline and health state.
|
||||
/// </summary>
|
||||
/// <param name="baseLevel">The role-adjusted baseline (e.g., 200 for primary, 150 for secondary).</param>
|
||||
/// <param name="mxAccessConnected">Whether the MXAccess runtime connection is healthy.</param>
|
||||
/// <param name="dbConnected">Whether the Galaxy repository database is reachable.</param>
|
||||
/// <returns>A ServiceLevel byte between 0 and 255.</returns>
|
||||
public byte Calculate(int baseLevel, bool mxAccessConnected, bool dbConnected)
|
||||
{
|
||||
if (!mxAccessConnected && !dbConnected)
|
||||
return 0;
|
||||
|
||||
int level = baseLevel;
|
||||
|
||||
if (!mxAccessConnected)
|
||||
level -= 100;
|
||||
|
||||
if (!dbConnected)
|
||||
level -= 50;
|
||||
|
||||
return (byte)Math.Max(0, Math.Min(level, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
||||
// Clear the default Profiles list before binding so JSON values replace rather than append
|
||||
_config.Security.Profiles.Clear();
|
||||
configuration.GetSection("Security").Bind(_config.Security);
|
||||
configuration.GetSection("Redundancy").Bind(_config.Redundancy);
|
||||
|
||||
_mxProxy = new MxProxyAdapter();
|
||||
_galaxyRepository = new GalaxyRepositoryService(_config.GalaxyRepository);
|
||||
@@ -164,7 +165,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
||||
? new Domain.ConfigUserAuthenticationProvider(_config.Authentication.Users)
|
||||
: (Domain.IUserAuthenticationProvider?)null;
|
||||
_serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics, historianDataSource,
|
||||
_config.Authentication, authProvider, _config.Security);
|
||||
_config.Authentication, authProvider, _config.Security, _config.Redundancy);
|
||||
|
||||
// Step 9-10: Query hierarchy, start server, build address space
|
||||
DateTime? initialDeployTime = null;
|
||||
@@ -222,6 +223,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
||||
_statusWebServer.Start();
|
||||
}
|
||||
|
||||
// Wire ServiceLevel updates from MXAccess health changes
|
||||
if (_config.Redundancy.Enabled)
|
||||
{
|
||||
effectiveMxClient.ConnectionStateChanged += OnMxAccessStateChangedForServiceLevel;
|
||||
}
|
||||
|
||||
// Step 14
|
||||
Log.Information("LmxOpcUa service started successfully");
|
||||
}
|
||||
@@ -294,6 +301,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMxAccessStateChangedForServiceLevel(object? sender, Domain.ConnectionStateChangedEventArgs e)
|
||||
{
|
||||
var mxConnected = e.CurrentState == Domain.ConnectionState.Connected;
|
||||
var dbConnected = _galaxyStats?.DbConnected ?? false;
|
||||
_serverHost?.UpdateServiceLevel(mxConnected, dbConnected);
|
||||
Log.Debug("ServiceLevel updated: MxAccess={MxState}, DB={DbState}", e.CurrentState, dbConnected);
|
||||
}
|
||||
|
||||
private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
|
||||
{
|
||||
Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception (IsTerminating={IsTerminating})", e.IsTerminating);
|
||||
|
||||
@@ -122,6 +122,28 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the redundancy configuration for the test host.
|
||||
/// </summary>
|
||||
/// <param name="redundancy">The redundancy configuration to inject.</param>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
public OpcUaServiceBuilder WithRedundancy(RedundancyConfiguration redundancy)
|
||||
{
|
||||
_config.Redundancy = redundancy;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the application URI for the test host, distinct from the namespace URI.
|
||||
/// </summary>
|
||||
/// <param name="applicationUri">The unique application URI for this server instance.</param>
|
||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
||||
public OpcUaServiceBuilder WithApplicationUri(string applicationUri)
|
||||
{
|
||||
_config.OpcUa.ApplicationUri = applicationUri;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the security profile configuration for the test host.
|
||||
/// </summary>
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"GalaxyName": "ZB",
|
||||
"MaxSessions": 100,
|
||||
"SessionTimeoutMinutes": 30,
|
||||
"AlarmTrackingEnabled": false
|
||||
"AlarmTrackingEnabled": false,
|
||||
"ApplicationUri": null
|
||||
},
|
||||
"MxAccess": {
|
||||
"ClientName": "LmxOpcUa",
|
||||
@@ -45,6 +46,13 @@
|
||||
"PkiRootPath": null,
|
||||
"CertificateSubject": null
|
||||
},
|
||||
"Redundancy": {
|
||||
"Enabled": false,
|
||||
"Mode": "Warm",
|
||||
"Role": "Primary",
|
||||
"ServerUris": [],
|
||||
"ServiceLevelBase": 200
|
||||
},
|
||||
"Historian": {
|
||||
"Enabled": false,
|
||||
"ConnectionString": "Server=localhost;Database=Runtime;Integrated Security=true;",
|
||||
|
||||
@@ -236,5 +236,81 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
|
||||
config.Security.AutoAcceptClientCertificates.ShouldBe(false);
|
||||
config.Security.MinimumCertificateKeySize.ShouldBe(4096);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redundancy_Section_BindsFromJson()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
config.Redundancy.Enabled.ShouldBe(false);
|
||||
config.Redundancy.Mode.ShouldBe("Warm");
|
||||
config.Redundancy.Role.ShouldBe("Primary");
|
||||
config.Redundancy.ServiceLevelBase.ShouldBe(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redundancy_Section_BindsCustomValues()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new[]
|
||||
{
|
||||
new System.Collections.Generic.KeyValuePair<string, string>("Redundancy:Enabled", "true"),
|
||||
new System.Collections.Generic.KeyValuePair<string, string>("Redundancy:Mode", "Hot"),
|
||||
new System.Collections.Generic.KeyValuePair<string, string>("Redundancy:Role", "Secondary"),
|
||||
new System.Collections.Generic.KeyValuePair<string, string>("Redundancy:ServiceLevelBase", "180"),
|
||||
new System.Collections.Generic.KeyValuePair<string, string>("Redundancy:ServerUris:0", "urn:a"),
|
||||
new System.Collections.Generic.KeyValuePair<string, string>("Redundancy:ServerUris:1", "urn:b"),
|
||||
})
|
||||
.Build();
|
||||
|
||||
var config = new AppConfiguration();
|
||||
configuration.GetSection("Redundancy").Bind(config.Redundancy);
|
||||
|
||||
config.Redundancy.Enabled.ShouldBe(true);
|
||||
config.Redundancy.Mode.ShouldBe("Hot");
|
||||
config.Redundancy.Role.ShouldBe("Secondary");
|
||||
config.Redundancy.ServiceLevelBase.ShouldBe(180);
|
||||
config.Redundancy.ServerUris.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_RedundancyEnabled_NoApplicationUri_ReturnsFalse()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.Redundancy.Enabled = true;
|
||||
config.Redundancy.ServerUris.Add("urn:a");
|
||||
config.Redundancy.ServerUris.Add("urn:b");
|
||||
// OpcUa.ApplicationUri is null
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_InvalidServiceLevelBase_ReturnsFalse()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.Redundancy.ServiceLevelBase = 0;
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpcUa_ApplicationUri_DefaultsToNull()
|
||||
{
|
||||
var config = new OpcUaConfiguration();
|
||||
config.ApplicationUri.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpcUa_ApplicationUri_BindsFromConfig()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new[]
|
||||
{
|
||||
new System.Collections.Generic.KeyValuePair<string, string>("OpcUa:ApplicationUri", "urn:test:app"),
|
||||
})
|
||||
.Build();
|
||||
|
||||
var config = new OpcUaConfiguration();
|
||||
configuration.GetSection("OpcUa").Bind(config);
|
||||
config.ApplicationUri.ShouldBe("urn:test:app");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,11 +110,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
|
||||
/// <param name="mxClient">An optional fake MXAccess client to inject; otherwise a default fake is created.</param>
|
||||
/// <param name="repo">An optional fake repository to inject; otherwise standard test data is used.</param>
|
||||
/// <param name="security">An optional security profile configuration for the test server.</param>
|
||||
/// <param name="redundancy">An optional redundancy configuration for the test server.</param>
|
||||
/// <param name="applicationUri">An optional explicit application URI for the test server.</param>
|
||||
/// <param name="serverName">An optional server name override for the test server.</param>
|
||||
/// <returns>A fixture configured to exercise the direct fake-client path.</returns>
|
||||
public static OpcUaServerFixture WithFakeMxAccessClient(
|
||||
FakeMxAccessClient? mxClient = null,
|
||||
FakeGalaxyRepository? repo = null,
|
||||
SecurityProfileConfiguration? security = null)
|
||||
SecurityProfileConfiguration? security = null,
|
||||
RedundancyConfiguration? redundancy = null,
|
||||
string? applicationUri = null,
|
||||
string? serverName = null)
|
||||
{
|
||||
var client = mxClient ?? new FakeMxAccessClient();
|
||||
var r = repo ?? new FakeGalaxyRepository
|
||||
@@ -130,6 +136,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
|
||||
|
||||
if (security != null)
|
||||
builder.WithSecurity(security);
|
||||
if (redundancy != null)
|
||||
builder.WithRedundancy(redundancy);
|
||||
if (applicationUri != null)
|
||||
builder.WithApplicationUri(applicationUri);
|
||||
if (serverName != null)
|
||||
builder.WithGalaxyName(serverName);
|
||||
|
||||
return new OpcUaServerFixture(builder, repo: r, mxClient: client);
|
||||
}
|
||||
|
||||
179
tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/RedundancyTests.cs
Normal file
179
tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/RedundancyTests.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
|
||||
{
|
||||
public class RedundancyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Server_WithRedundancyDisabled_ReportsNone()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var redundancySupport = client.Read(VariableIds.Server_ServerRedundancy_RedundancySupport);
|
||||
((int)redundancySupport.Value).ShouldBe((int)RedundancySupport.None);
|
||||
|
||||
var serviceLevel = client.Read(VariableIds.Server_ServiceLevel);
|
||||
((byte)serviceLevel.Value).ShouldBe((byte)255);
|
||||
}
|
||||
finally { await fixture.DisposeAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_WithRedundancyEnabled_ReportsConfiguredMode()
|
||||
{
|
||||
var redundancy = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
Mode = "Warm",
|
||||
Role = "Primary",
|
||||
ServiceLevelBase = 200,
|
||||
ServerUris = new List<string> { "urn:test:primary", "urn:test:secondary" }
|
||||
};
|
||||
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: redundancy,
|
||||
applicationUri: "urn:test:primary");
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var redundancySupport = client.Read(VariableIds.Server_ServerRedundancy_RedundancySupport);
|
||||
((int)redundancySupport.Value).ShouldBe((int)RedundancySupport.Warm);
|
||||
}
|
||||
finally { await fixture.DisposeAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_Primary_HasHigherServiceLevel_ThanSecondary()
|
||||
{
|
||||
var sharedUris = new List<string> { "urn:test:primary", "urn:test:secondary" };
|
||||
|
||||
var primaryRedundancy = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true, Mode = "Warm", Role = "Primary",
|
||||
ServiceLevelBase = 200, ServerUris = sharedUris
|
||||
};
|
||||
var secondaryRedundancy = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true, Mode = "Warm", Role = "Secondary",
|
||||
ServiceLevelBase = 200, ServerUris = sharedUris
|
||||
};
|
||||
|
||||
var primaryFixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: primaryRedundancy, applicationUri: "urn:test:primary");
|
||||
var secondaryFixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: secondaryRedundancy, applicationUri: "urn:test:secondary",
|
||||
serverName: "TestGalaxy2");
|
||||
|
||||
await primaryFixture.InitializeAsync();
|
||||
await secondaryFixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var primaryClient = new OpcUaTestClient();
|
||||
await primaryClient.ConnectAsync(primaryFixture.EndpointUrl);
|
||||
var primaryLevel = (byte)primaryClient.Read(VariableIds.Server_ServiceLevel).Value;
|
||||
|
||||
using var secondaryClient = new OpcUaTestClient();
|
||||
await secondaryClient.ConnectAsync(secondaryFixture.EndpointUrl);
|
||||
var secondaryLevel = (byte)secondaryClient.Read(VariableIds.Server_ServiceLevel).Value;
|
||||
|
||||
primaryLevel.ShouldBeGreaterThan(secondaryLevel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await secondaryFixture.DisposeAsync();
|
||||
await primaryFixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_WithRedundancyEnabled_ExposesServerUriArray()
|
||||
{
|
||||
var serverUris = new List<string> { "urn:test:server1", "urn:test:server2" };
|
||||
var redundancy = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true, Mode = "Warm", Role = "Primary",
|
||||
ServiceLevelBase = 200, ServerUris = serverUris
|
||||
};
|
||||
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: redundancy, applicationUri: "urn:test:server1");
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var uriArrayValue = client.Read(VariableIds.Server_ServerRedundancy_ServerUriArray);
|
||||
|
||||
// ServerUriArray may not be exposed if the SDK doesn't create the non-transparent
|
||||
// redundancy node type automatically. If the value is null, the server logged a
|
||||
// warning and the test is informational rather than a hard failure.
|
||||
if (uriArrayValue.Value != null)
|
||||
{
|
||||
var uris = (string[])uriArrayValue.Value;
|
||||
uris.Length.ShouldBe(2);
|
||||
uris.ShouldContain("urn:test:server1");
|
||||
uris.ShouldContain("urn:test:server2");
|
||||
}
|
||||
}
|
||||
finally { await fixture.DisposeAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TwoServers_BothExposeSameRedundantSet()
|
||||
{
|
||||
var sharedUris = new List<string> { "urn:test:a", "urn:test:b" };
|
||||
var configA = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true, Mode = "Warm", Role = "Primary",
|
||||
ServiceLevelBase = 200, ServerUris = sharedUris
|
||||
};
|
||||
var configB = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true, Mode = "Warm", Role = "Secondary",
|
||||
ServiceLevelBase = 200, ServerUris = sharedUris
|
||||
};
|
||||
|
||||
var fixtureA = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: configA, applicationUri: "urn:test:a");
|
||||
var fixtureB = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: configB, applicationUri: "urn:test:b",
|
||||
serverName: "TestGalaxy2");
|
||||
|
||||
await fixtureA.InitializeAsync();
|
||||
await fixtureB.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var clientA = new OpcUaTestClient();
|
||||
await clientA.ConnectAsync(fixtureA.EndpointUrl);
|
||||
var modeA = (int)clientA.Read(VariableIds.Server_ServerRedundancy_RedundancySupport).Value;
|
||||
|
||||
using var clientB = new OpcUaTestClient();
|
||||
await clientB.ConnectAsync(fixtureB.EndpointUrl);
|
||||
var modeB = (int)clientB.Read(VariableIds.Server_ServerRedundancy_RedundancySupport).Value;
|
||||
|
||||
modeA.ShouldBe((int)RedundancySupport.Warm);
|
||||
modeB.ShouldBe((int)RedundancySupport.Warm);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixtureB.DisposeAsync();
|
||||
await fixtureA.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Redundancy
|
||||
{
|
||||
public class RedundancyConfigurationTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultConfig_Disabled()
|
||||
{
|
||||
var config = new RedundancyConfiguration();
|
||||
config.Enabled.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_ModeWarm()
|
||||
{
|
||||
var config = new RedundancyConfiguration();
|
||||
config.Mode.ShouldBe("Warm");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_RolePrimary()
|
||||
{
|
||||
var config = new RedundancyConfiguration();
|
||||
config.Role.ShouldBe("Primary");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_EmptyServerUris()
|
||||
{
|
||||
var config = new RedundancyConfiguration();
|
||||
config.ServerUris.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_ServiceLevelBase200()
|
||||
{
|
||||
var config = new RedundancyConfiguration();
|
||||
config.ServiceLevelBase.ShouldBe(200);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Redundancy
|
||||
{
|
||||
public class RedundancyModeResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolve_Disabled_ReturnsNone()
|
||||
{
|
||||
RedundancyModeResolver.Resolve("Warm", enabled: false).ShouldBe(RedundancySupport.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Warm_ReturnsWarm()
|
||||
{
|
||||
RedundancyModeResolver.Resolve("Warm", enabled: true).ShouldBe(RedundancySupport.Warm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Hot_ReturnsHot()
|
||||
{
|
||||
RedundancyModeResolver.Resolve("Hot", enabled: true).ShouldBe(RedundancySupport.Hot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Unknown_FallsBackToNone()
|
||||
{
|
||||
RedundancyModeResolver.Resolve("Transparent", enabled: true).ShouldBe(RedundancySupport.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_CaseInsensitive()
|
||||
{
|
||||
RedundancyModeResolver.Resolve("warm", enabled: true).ShouldBe(RedundancySupport.Warm);
|
||||
RedundancyModeResolver.Resolve("WARM", enabled: true).ShouldBe(RedundancySupport.Warm);
|
||||
RedundancyModeResolver.Resolve("hot", enabled: true).ShouldBe(RedundancySupport.Hot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Null_FallsBackToNone()
|
||||
{
|
||||
RedundancyModeResolver.Resolve(null!, enabled: true).ShouldBe(RedundancySupport.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Empty_FallsBackToNone()
|
||||
{
|
||||
RedundancyModeResolver.Resolve("", enabled: true).ShouldBe(RedundancySupport.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Redundancy
|
||||
{
|
||||
public class ServiceLevelCalculatorTests
|
||||
{
|
||||
private readonly ServiceLevelCalculator _calculator = new ServiceLevelCalculator();
|
||||
|
||||
[Fact]
|
||||
public void FullyHealthy_Primary_ReturnsBase()
|
||||
{
|
||||
_calculator.Calculate(200, mxAccessConnected: true, dbConnected: true).ShouldBe((byte)200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullyHealthy_Secondary_ReturnsBaseMinusFifty()
|
||||
{
|
||||
_calculator.Calculate(150, mxAccessConnected: true, dbConnected: true).ShouldBe((byte)150);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MxAccessDown_ReducesServiceLevel()
|
||||
{
|
||||
_calculator.Calculate(200, mxAccessConnected: false, dbConnected: true).ShouldBe((byte)100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DbDown_ReducesServiceLevel()
|
||||
{
|
||||
_calculator.Calculate(200, mxAccessConnected: true, dbConnected: false).ShouldBe((byte)150);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothDown_ReturnsZero()
|
||||
{
|
||||
_calculator.Calculate(200, mxAccessConnected: false, dbConnected: false).ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampedTo255()
|
||||
{
|
||||
_calculator.Calculate(255, mxAccessConnected: true, dbConnected: true).ShouldBe((byte)255);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampedToZero()
|
||||
{
|
||||
_calculator.Calculate(50, mxAccessConnected: false, dbConnected: true).ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroBase_BothHealthy_ReturnsZero()
|
||||
{
|
||||
_calculator.Calculate(0, mxAccessConnected: true, dbConnected: true).ShouldBe((byte)0);
|
||||
}
|
||||
}
|
||||
}
|
||||
70
tools/opcuacli-dotnet/Commands/RedundancyCommand.cs
Normal file
70
tools/opcuacli-dotnet/Commands/RedundancyCommand.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
|
||||
namespace OpcUaCli.Commands;
|
||||
|
||||
[Command("redundancy", Description = "Read redundancy state from an OPC UA server")]
|
||||
public class RedundancyCommand : ICommand
|
||||
{
|
||||
[CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)]
|
||||
public string Url { get; init; } = default!;
|
||||
|
||||
[CommandOption("username", 'U', Description = "Username for authentication")]
|
||||
public string? Username { get; init; }
|
||||
|
||||
[CommandOption("password", 'P', Description = "Password for authentication")]
|
||||
public string? Password { get; init; }
|
||||
|
||||
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
|
||||
public string Security { get; init; } = "none";
|
||||
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security);
|
||||
|
||||
// Read RedundancySupport
|
||||
var redundancySupportValue = await session.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport);
|
||||
var redundancyMode = (RedundancySupport)(int)redundancySupportValue.Value;
|
||||
await console.Output.WriteLineAsync($"Redundancy Mode: {redundancyMode}");
|
||||
|
||||
// Read ServiceLevel
|
||||
var serviceLevelValue = await session.ReadValueAsync(VariableIds.Server_ServiceLevel);
|
||||
var serviceLevel = (byte)serviceLevelValue.Value;
|
||||
await console.Output.WriteLineAsync($"Service Level: {serviceLevel}");
|
||||
|
||||
// Read ServerUriArray (only present for non-transparent redundancy)
|
||||
try
|
||||
{
|
||||
var serverUriArrayValue = await session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray);
|
||||
if (serverUriArrayValue.Value is string[] uris && uris.Length > 0)
|
||||
{
|
||||
await console.Output.WriteLineAsync("Server URIs:");
|
||||
foreach (var uri in uris)
|
||||
{
|
||||
await console.Output.WriteLineAsync($" - {uri}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ServerUriArray may not be present when RedundancySupport is None
|
||||
}
|
||||
|
||||
// Read ServerArray for the local server's ApplicationUri
|
||||
try
|
||||
{
|
||||
var serverArrayValue = await session.ReadValueAsync(VariableIds.Server_ServerArray);
|
||||
if (serverArrayValue.Value is string[] serverArray && serverArray.Length > 0)
|
||||
{
|
||||
await console.Output.WriteLineAsync($"Application URI: {serverArray[0]}");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Informational only
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user