Compare commits
32 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8464e3f376 | ||
| a9357600e7 | |||
|
|
2f00c74bbb | ||
| 5d5e1f9650 | |||
|
|
4886a5783f | ||
| d70a2e0077 | |||
|
|
cb7b81a87a | ||
| 901d2b8019 | |||
|
|
d5fa1f450e | ||
| 6fdaee3a71 | |||
|
|
ed88835d34 | ||
| 5389d4d22d | |||
|
|
b5f8661e98 | ||
| 4058b88784 | |||
|
|
6b04a85f86 | ||
| cd8691280a | |||
|
|
77d09bf64e | ||
| 163c821e74 | |||
|
|
eea31dcc4e | ||
| 8a692d4ba8 | |||
|
|
268b12edec | ||
| edce1be742 | |||
|
|
18b3e24710 | ||
| f6a12dafe9 | |||
|
|
058c3dddd3 | ||
| 52791952dd | |||
|
|
860deb8e0d | ||
| f5e7173de3 | |||
|
|
22d3b0d23c | ||
| 55696a8750 | |||
|
|
dd3a449308 | ||
| 3c1dc334f9 |
@@ -8,8 +8,7 @@
|
|||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj"/>
|
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
||||||
@@ -24,9 +23,8 @@
|
|||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/ZB.MOM.WW.OtOpcUa.Tests.v1Archive.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj"/>
|
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>
|
||||||
|
|||||||
@@ -348,6 +348,44 @@ The project uses [GLAuth](https://github.com/glauth/glauth) v2.4.0 as the LDAP s
|
|||||||
|
|
||||||
Enable LDAP in `appsettings.json` under `Authentication.Ldap`. See [Configuration Guide](Configuration.md) for the full property reference.
|
Enable LDAP in `appsettings.json` under `Authentication.Ldap`. See [Configuration Guide](Configuration.md) for the full property reference.
|
||||||
|
|
||||||
|
### Active Directory configuration
|
||||||
|
|
||||||
|
Production deployments typically point at Active Directory instead of GLAuth. Only four properties differ from the dev defaults: `Server`, `Port`, `UserNameAttribute`, and `ServiceAccountDn`. The same `GroupToRole` mechanism works — map your AD security groups to OPC UA roles.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"OpcUaServer": {
|
||||||
|
"Ldap": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Server": "dc01.corp.example.com",
|
||||||
|
"Port": 636,
|
||||||
|
"UseTls": true,
|
||||||
|
"AllowInsecureLdap": false,
|
||||||
|
"SearchBase": "DC=corp,DC=example,DC=com",
|
||||||
|
"ServiceAccountDn": "CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com",
|
||||||
|
"ServiceAccountPassword": "<from your secret store>",
|
||||||
|
"DisplayNameAttribute": "displayName",
|
||||||
|
"GroupAttribute": "memberOf",
|
||||||
|
"UserNameAttribute": "sAMAccountName",
|
||||||
|
"GroupToRole": {
|
||||||
|
"OPCUA-Operators": "WriteOperate",
|
||||||
|
"OPCUA-Engineers": "WriteConfigure",
|
||||||
|
"OPCUA-AlarmAck": "AlarmAck",
|
||||||
|
"OPCUA-Tuners": "WriteTune"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `UserNameAttribute: "sAMAccountName"` is the critical AD override — the default `uid` is not populated on AD user entries, so the user-DN lookup returns no results without it. Use `userPrincipalName` instead if operators log in with `user@corp.example.com` form.
|
||||||
|
- `Port: 636` + `UseTls: true` is required under AD's LDAP-signing enforcement. AD increasingly rejects plain-LDAP bind; set `AllowInsecureLdap: false` to refuse fallback.
|
||||||
|
- `ServiceAccountDn` should name a dedicated read-only service principal — not a privileged admin. The account needs read access to user and group entries in the search base.
|
||||||
|
- `memberOf` values come back as full DNs like `CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com`. The authenticator strips the leading `CN=` RDN value so operators configure `GroupToRole` with readable group common-names.
|
||||||
|
- Nested group membership is **not** expanded — assign users directly to the role-mapped groups, or pre-flatten membership in AD. `LDAP_MATCHING_RULE_IN_CHAIN` / `tokenGroups` expansion is an authenticator enhancement, not a config change.
|
||||||
|
|
||||||
### Security Considerations
|
### Security Considerations
|
||||||
|
|
||||||
- LDAP credentials are transmitted in plaintext over the OPC UA channel unless transport security is enabled. Use `Basic256Sha256-SignAndEncrypt` for production deployments.
|
- LDAP credentials are transmitted in plaintext over the OPC UA channel unless transport security is enabled. Use `Basic256Sha256-SignAndEncrypt` for production deployments.
|
||||||
|
|||||||
120
docs/v2/lmx-followups.md
Normal file
120
docs/v2/lmx-followups.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# LMX Galaxy bridge — remaining follow-ups
|
||||||
|
|
||||||
|
State after PR 19: the Galaxy driver is functionally at v1 parity through the
|
||||||
|
`IDriver` abstraction; the OPC UA server runs with LDAP-authenticated
|
||||||
|
Basic256Sha256 endpoints and alarms are observable through
|
||||||
|
`AlarmConditionState.ReportEvent`. The items below are what remains LMX-
|
||||||
|
specific before the stack can fully replace the v1 deployment, in
|
||||||
|
rough priority order.
|
||||||
|
|
||||||
|
## 1. Proxy-side `IHistoryProvider` for `ReadAtTime` / `ReadEvents`
|
||||||
|
|
||||||
|
**Status**: Host-side IPC shipped (PR 10 + PR 11). Proxy consumer not written.
|
||||||
|
|
||||||
|
PR 10 added `HistoryReadAtTimeRequest/Response` on the IPC wire and
|
||||||
|
`MxAccessGalaxyBackend.HistoryReadAtTimeAsync` delegates to
|
||||||
|
`HistorianDataSource.ReadAtTimeAsync`. PR 11 did the same for events
|
||||||
|
(`HistoryReadEventsRequest/Response` + `GalaxyHistoricalEvent`). The Proxy
|
||||||
|
side (`GalaxyProxyDriver`) doesn't call those yet — `Core.Abstractions.IHistoryProvider`
|
||||||
|
only exposes `ReadRawAsync` + `ReadProcessedAsync`.
|
||||||
|
|
||||||
|
**To do**:
|
||||||
|
- Extend `IHistoryProvider` with `ReadAtTimeAsync(string, DateTime[], …)` and
|
||||||
|
`ReadEventsAsync(string?, DateTime, DateTime, int, …)`.
|
||||||
|
- `GalaxyProxyDriver` calls the new IPC message kinds.
|
||||||
|
- `DriverNodeManager` wires the new capability methods onto `HistoryRead`
|
||||||
|
`AtTime` + `Events` service handlers.
|
||||||
|
- Integration test: OPC UA client calls `HistoryReadAtTime` / `HistoryReadEvents`,
|
||||||
|
value flows through IPC to the Host's `HistorianDataSource`, back to the client.
|
||||||
|
|
||||||
|
## 2. Write-gating by role — **DONE (PR 26)**
|
||||||
|
|
||||||
|
Landed in PR 26. `WriteAuthzPolicy` in `Server/Security/` maps
|
||||||
|
`SecurityClassification` → required role (`FreeAccess` → no role required,
|
||||||
|
`Operate`/`SecuredWrite` → `WriteOperate`, `Tune` → `WriteTune`,
|
||||||
|
`Configure`/`VerifiedWrite` → `WriteConfigure`, `ViewOnly` → deny regardless).
|
||||||
|
`DriverNodeManager` caches the classification per variable during discovery and
|
||||||
|
checks the session's roles (via `IRoleBearer`) in `OnWriteValue` before calling
|
||||||
|
`IWritable.WriteAsync`. Roles do not cascade — a session with `WriteOperate`
|
||||||
|
can't write a `Tune` attribute unless it also carries `WriteTune`.
|
||||||
|
|
||||||
|
See `feedback_acl_at_server_layer.md` in memory for the architectural directive
|
||||||
|
that authz stays at the server layer and never delegates to driver-specific auth.
|
||||||
|
|
||||||
|
## 3. Admin UI client-cert trust management — **DONE (PR 28)**
|
||||||
|
|
||||||
|
PR 28 shipped `/certificates` in the Admin UI. `CertTrustService` reads the OPC
|
||||||
|
UA server's PKI store root (`OpcUaServerOptions.PkiStoreRoot` — default
|
||||||
|
`%ProgramData%\OtOpcUa\pki`) and lists rejected + trusted certs by parsing the
|
||||||
|
`.der` files directly, so it has no `Opc.Ua` dependency and runs on any
|
||||||
|
Admin host that can reach the shared PKI directory.
|
||||||
|
|
||||||
|
Operator actions: Trust (moves `rejected/certs/*.der` → `trusted/certs/*.der`),
|
||||||
|
Delete rejected, Revoke trust. The OPC UA stack re-reads the trusted store on
|
||||||
|
each new client handshake, so no explicit reload signal is needed —
|
||||||
|
operators retry the rejected client's connection after trusting.
|
||||||
|
|
||||||
|
Deferred: flipping `AutoAcceptUntrustedClientCertificates` to `false` as the
|
||||||
|
deployment default. That's a production-hardening config change, not a code
|
||||||
|
gap — the Admin UI is now ready to be the trust gate.
|
||||||
|
|
||||||
|
## 4. Live-LDAP integration test — **DONE (PR 31)**
|
||||||
|
|
||||||
|
PR 31 shipped `Server.Tests/LdapUserAuthenticatorLiveTests.cs` — 6 live-bind
|
||||||
|
tests against the dev GLAuth instance at `localhost:3893`, skipped cleanly
|
||||||
|
when the port is unreachable. Covers: valid bind, wrong password, unknown
|
||||||
|
user, empty credentials, single-group → WriteOperate mapping, multi-group
|
||||||
|
admin user surfacing all mapped roles.
|
||||||
|
|
||||||
|
Also added `UserNameAttribute` to `LdapOptions` (default `uid` for RFC 2307
|
||||||
|
compat) so Active Directory deployments can configure `sAMAccountName` /
|
||||||
|
`userPrincipalName` without code changes. `LdapUserAuthenticatorAdCompatTests`
|
||||||
|
(5 unit guards) pins the AD-shape DN parsing + filter escape behaviors. See
|
||||||
|
`docs/security.md` §"Active Directory configuration" for the AD appsettings
|
||||||
|
snippet.
|
||||||
|
|
||||||
|
Deferred: asserting `session.Identity` end-to-end on the server side (i.e.
|
||||||
|
drive a full OPC UA session with username/password, then read an
|
||||||
|
`IHostConnectivityProbe`-style "whoami" node to verify the role surfaced).
|
||||||
|
That needs a test-only address-space node and is a separate PR.
|
||||||
|
|
||||||
|
## 5. Full Galaxy live-service smoke test against the merged v2 stack
|
||||||
|
|
||||||
|
**Status**: Individual pieces have live smoke tests (PR 5 MXAccess, PR 13
|
||||||
|
probe manager, PR 14 alarm tracker), but the full loop — OPC UA client →
|
||||||
|
`OtOpcUaServer` → `GalaxyProxyDriver` (in-process) → named-pipe to
|
||||||
|
Galaxy.Host subprocess → live MXAccess runtime → real Galaxy objects — has
|
||||||
|
no single end-to-end smoke test.
|
||||||
|
|
||||||
|
**To do**:
|
||||||
|
- Test that spawns the full topology, discovers a deployed Galaxy object,
|
||||||
|
subscribes to one of its attributes, writes a value back, and asserts the
|
||||||
|
write round-tripped through MXAccess. Skip when ArchestrA isn't running.
|
||||||
|
|
||||||
|
## 6. Second driver instance on the same server — **DONE (PR 32)**
|
||||||
|
|
||||||
|
`Server.Tests/MultipleDriverInstancesIntegrationTests.cs` registers two
|
||||||
|
drivers with distinct `DriverInstanceId`s on one `DriverHost`, spins up the
|
||||||
|
full OPC UA server, and asserts three behaviors: (1) each driver's namespace
|
||||||
|
URI (`urn:OtOpcUa:{id}`) resolves to a distinct index in the client's
|
||||||
|
NamespaceUris, (2) browsing one subtree returns that driver's folder and
|
||||||
|
does NOT leak the other driver's folder, (3) reads route to the correct
|
||||||
|
driver — the alpha instance returns 42 while beta returns 99, so a misroute
|
||||||
|
would surface at the assertion layer.
|
||||||
|
|
||||||
|
Deferred: the alarm-event multi-driver parity case (two drivers each raising
|
||||||
|
a `GalaxyAlarmEvent`, assert each condition lands on its owning instance's
|
||||||
|
condition node). Alarm tracking already has its own integration test
|
||||||
|
(`AlarmSubscription*`); the multi-driver alarm case would need a stub
|
||||||
|
`IAlarmSource` that's worth its own focused PR.
|
||||||
|
|
||||||
|
## 7. Host-status per-AppEngine granularity → Admin UI dashboard
|
||||||
|
|
||||||
|
**Status**: PR 13 ships per-platform/per-AppEngine `ScanState` probing; PR 17
|
||||||
|
surfaces the resulting `OnHostStatusChanged` events through OPC UA. Admin
|
||||||
|
UI doesn't render a per-host dashboard yet.
|
||||||
|
|
||||||
|
**To do**:
|
||||||
|
- SignalR hub push of `HostStatusChangedEventArgs` to the Admin UI.
|
||||||
|
- Dashboard page showing each tracked host, current state, last transition
|
||||||
|
time, failure count.
|
||||||
108
docs/v2/modbus-test-plan.md
Normal file
108
docs/v2/modbus-test-plan.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Modbus driver — test plan + device-quirk catalog
|
||||||
|
|
||||||
|
The Modbus TCP driver unit tests (PRs 21–24) cover the protocol surface against an
|
||||||
|
in-memory fake transport. They validate the codec, state machine, and function-code
|
||||||
|
routing against a textbook Modbus server. That's necessary but not sufficient: real PLC
|
||||||
|
populations disagree with the spec in small, device-specific ways, and a driver that
|
||||||
|
passes textbook tests can still misbehave against actual equipment.
|
||||||
|
|
||||||
|
This doc is the harness-and-quirks playbook. The project it describes lives at
|
||||||
|
`tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/` — scaffolded in PR 30 with
|
||||||
|
the simulator fixture, DL205 profile stub, and one write/read smoke test. Each
|
||||||
|
confirmed DL205 quirk lands in a follow-up PR as a named test in that project.
|
||||||
|
|
||||||
|
## Harness
|
||||||
|
|
||||||
|
**Chosen simulator: ModbusPal** (Java, scriptable). Rationale:
|
||||||
|
- Scriptable enough to mimic device-specific behaviors (non-standard register
|
||||||
|
layouts, custom exception codes, intentional response delays).
|
||||||
|
- Runs locally, no CI dependency. Tests skip when `localhost:502` (or the configured
|
||||||
|
simulator endpoint) isn't reachable.
|
||||||
|
- Free + long-maintained — physical PLC bench is unavailable in most dev
|
||||||
|
environments, and renting cloud PLCs isn't worth the per-test cost.
|
||||||
|
|
||||||
|
**Setup pattern** (not yet codified in a script — will land alongside the integration
|
||||||
|
test project):
|
||||||
|
1. Install ModbusPal, load the per-device `.xmpp` profile from
|
||||||
|
`tests/Driver.Modbus.IntegrationTests/ModbusPal/` (TBD directory).
|
||||||
|
2. Start the simulator listening on `localhost:502` (or override via
|
||||||
|
`MODBUS_SIM_ENDPOINT` env var).
|
||||||
|
3. `dotnet test` the integration project — tests auto-skip when the endpoint is
|
||||||
|
unreachable, so forgetting to start the simulator doesn't wedge CI.
|
||||||
|
|
||||||
|
## Per-device quirk catalog
|
||||||
|
|
||||||
|
### AutomationDirect DL205
|
||||||
|
|
||||||
|
First known target device. Quirks to document and cover with named tests (to be
|
||||||
|
filled in when user validates each behavior in ModbusPal with a DL205 profile):
|
||||||
|
|
||||||
|
- **Word order for 32-bit values**: _pending_ — confirm whether DL205 uses ABCD
|
||||||
|
(Modbus TCP standard) or CDAB (Siemens-style word-swap) for Int32/UInt32/Float32.
|
||||||
|
Test name: `DL205_Float32_word_order_is_CDAB` (or `ABCD`, whichever proves out).
|
||||||
|
- **Register-zero access**: _pending_ — some DL205 configurations reject FC03 at
|
||||||
|
register 0 with exception code 02 (illegal data address). If confirmed, the
|
||||||
|
integration test suite verifies `ModbusProbeOptions.ProbeAddress` default of 0
|
||||||
|
triggers the rejection and operators must override; test name:
|
||||||
|
`DL205_FC03_at_register_0_returns_IllegalDataAddress`.
|
||||||
|
- **Coil addressing base**: _pending_ — DL205 documentation sometimes uses 1-based
|
||||||
|
coil addresses; verify the driver's zero-based addressing matches the physical
|
||||||
|
PLC without an off-by-one adjustment.
|
||||||
|
- **Maximum registers per FC03**: _pending_ — Modbus spec caps at 125; some DL205
|
||||||
|
models enforce a lower limit (e.g., 64). Test name:
|
||||||
|
`DL205_FC03_beyond_max_registers_returns_IllegalDataValue`.
|
||||||
|
- **Response framing under sustained load**: _pending_ — the driver's
|
||||||
|
single-flight semaphore assumes the server pairs requests/responses by
|
||||||
|
transaction id; at least one DL205 firmware revision is reported to drop the
|
||||||
|
TxId under load. If reproduced in ModbusPal we add a retry + log-and-continue
|
||||||
|
path to `ModbusTcpTransport`.
|
||||||
|
- **Exception code on coil write to a protected bit**: _pending_ — some DL205
|
||||||
|
setups protect internal coils; the driver should surface the PLC's exception
|
||||||
|
PDU as `BadNotWritable` rather than `BadInternalError`.
|
||||||
|
|
||||||
|
_User action item_: as each quirk is validated in ModbusPal, replace the _pending_
|
||||||
|
marker with the confirmed behavior and file a named test in the integration suite.
|
||||||
|
|
||||||
|
### Future devices
|
||||||
|
|
||||||
|
One section per device class, same shape as DL205. Quirks that apply across
|
||||||
|
multiple devices (e.g., "all AB PLCs use CDAB") can be noted in the cross-device
|
||||||
|
patterns section below once we have enough data points.
|
||||||
|
|
||||||
|
## Cross-device patterns
|
||||||
|
|
||||||
|
Once multiple device catalogs accumulate, quirks that recur across two or more
|
||||||
|
vendors get promoted into driver defaults or opt-in options:
|
||||||
|
|
||||||
|
- _(empty — filled in as catalogs grow)_
|
||||||
|
|
||||||
|
## Test conventions
|
||||||
|
|
||||||
|
- **One named test per quirk.** `DL205_word_order_is_CDAB_for_Float32` is easier to
|
||||||
|
diagnose on failure than a generic `Float32_roundtrip`. The `DL205_` prefix makes
|
||||||
|
filtering by device class trivial (`--filter "DisplayName~DL205"`).
|
||||||
|
- **Skip with a clear SkipReason.** Follow the pattern from
|
||||||
|
`GalaxyRepositoryLiveSmokeTests`: check reachability in the fixture, capture
|
||||||
|
a `SkipReason` string, and have each test call `Assert.Skip(SkipReason)` when
|
||||||
|
it's set. Don't throw — skipped tests read cleanly in CI logs.
|
||||||
|
- **Use the real `ModbusTcpTransport`.** Integration tests exercise the wire
|
||||||
|
protocol end-to-end. The in-memory `FakeTransport` from the unit test suite is
|
||||||
|
deliberately not used here — its value is speed + determinism, which doesn't
|
||||||
|
help reproduce device-specific issues.
|
||||||
|
- **Don't depend on ModbusPal state between tests.** Each test resets the
|
||||||
|
simulator's register bank or uses a unique address range. Avoid relying on
|
||||||
|
"previous test left value at register 10" setups that flake when tests run in
|
||||||
|
parallel or re-order.
|
||||||
|
|
||||||
|
## Next concrete PRs
|
||||||
|
|
||||||
|
- **PR 30 — Integration test project + DL205 profile scaffold** — **DONE**.
|
||||||
|
Shipped `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` with
|
||||||
|
`ModbusSimulatorFixture` (TCP-probe, skips with a clear `SkipReason` when the
|
||||||
|
endpoint is unreachable), `DL205/DL205Profile.cs` (tag map stub — one
|
||||||
|
writable holding register at address 100), and `DL205/DL205SmokeTests.cs`
|
||||||
|
(write-then-read round-trip). `ModbusPal/` directory holds the README
|
||||||
|
pointing at the to-be-committed `DL205.xmpp` profile.
|
||||||
|
- **PR 31+**: one PR per confirmed DL205 quirk, landing the named test + any
|
||||||
|
driver-side adjustment (e.g., retry on dropped TxId) needed to pass it. Drop
|
||||||
|
the `DL205.xmpp` profile into `ModbusPal/` alongside the first quirk PR.
|
||||||
@@ -5,15 +5,17 @@
|
|||||||
<h5 class="mb-4">OtOpcUa Admin</h5>
|
<h5 class="mb-4">OtOpcUa Admin</h5>
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item"><a class="nav-link text-light" href="/">Overview</a></li>
|
<li class="nav-item"><a class="nav-link text-light" href="/">Overview</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-light" href="/fleet">Fleet status</a></li>
|
||||||
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
|
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
|
||||||
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
|
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-light" href="/certificates">Certificates</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
<AuthorizeView>
|
<AuthorizeView>
|
||||||
<Authorized>
|
<Authorized>
|
||||||
<div class="small text-light">
|
<div class="small text-light">
|
||||||
Signed in as <strong>@context.User.Identity?.Name</strong>
|
Signed in as <a class="text-light" href="/account"><strong>@context.User.Identity?.Name</strong></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-muted">
|
<div class="small text-muted">
|
||||||
@string.Join(", ", context.User.Claims.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
|
@string.Join(", ", context.User.Claims.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
|
||||||
|
|||||||
129
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor
Normal file
129
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
@page "/account"
|
||||||
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||||
|
@using System.Security.Claims
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
|
||||||
|
<h1 class="mb-4">My account</h1>
|
||||||
|
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
@{
|
||||||
|
var username = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "—";
|
||||||
|
var displayName = context.User.Identity?.Name ?? "—";
|
||||||
|
var roles = context.User.Claims
|
||||||
|
.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToList();
|
||||||
|
var ldapGroups = context.User.Claims
|
||||||
|
.Where(c => c.Type == "ldap_group").Select(c => c.Value).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Identity</h5>
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-4">Username</dt><dd class="col-sm-8"><code>@username</code></dd>
|
||||||
|
<dt class="col-sm-4">Display name</dt><dd class="col-sm-8">@displayName</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Admin roles</h5>
|
||||||
|
@if (roles.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted mb-0">No Admin roles mapped — sign-in would have been blocked, so if you're seeing this, the session claim is likely stale.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mb-2">
|
||||||
|
@foreach (var r in roles)
|
||||||
|
{
|
||||||
|
<span class="badge bg-primary me-1">@r</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">LDAP groups: @(ldapGroups.Count == 0 ? "(none surfaced)" : string.Join(", ", ldapGroups))</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Capabilities</h5>
|
||||||
|
<p class="text-muted small">
|
||||||
|
Each Admin role grants a fixed capability set per <code>admin-ui.md</code> §Admin Roles.
|
||||||
|
Pages below reflect what this session can access; the route's <code>[Authorize]</code> guard
|
||||||
|
is the ground truth — this table mirrors it for readability.
|
||||||
|
</p>
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Capability</th>
|
||||||
|
<th>Required role(s)</th>
|
||||||
|
<th class="text-end">You have it?</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var cap in Capabilities)
|
||||||
|
{
|
||||||
|
var has = cap.RequiredRoles.Any(r => roles.Contains(r, StringComparer.OrdinalIgnoreCase));
|
||||||
|
<tr>
|
||||||
|
<td>@cap.Name<br /><small class="text-muted">@cap.Description</small></td>
|
||||||
|
<td>@string.Join(" or ", cap.RequiredRoles)</td>
|
||||||
|
<td class="text-end">
|
||||||
|
@if (has)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success">Yes</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">No</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<form method="post" action="/auth/logout">
|
||||||
|
<button class="btn btn-outline-danger" type="submit">Sign out</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private sealed record Capability(string Name, string Description, string[] RequiredRoles);
|
||||||
|
|
||||||
|
// Kept in sync with Program.cs authorization policies + each page's [Authorize] attribute.
|
||||||
|
// When a new page or policy is added, extend this list so operators can self-service check
|
||||||
|
// whether their session has access without trial-and-error navigation.
|
||||||
|
private static readonly IReadOnlyList<Capability> Capabilities =
|
||||||
|
[
|
||||||
|
new("View clusters + fleet status",
|
||||||
|
"Read-only access to the cluster list, fleet dashboard, and generation history.",
|
||||||
|
[AdminRoles.ConfigViewer, AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
|
||||||
|
new("Edit configuration drafts",
|
||||||
|
"Create and edit draft generations, manage namespace bindings and node ACLs. CanEdit policy.",
|
||||||
|
[AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
|
||||||
|
new("Publish generations",
|
||||||
|
"Promote a draft to Published — triggers node roll-out. CanPublish policy.",
|
||||||
|
[AdminRoles.FleetAdmin]),
|
||||||
|
new("Manage certificate trust",
|
||||||
|
"Trust rejected client certs + revoke trust. FleetAdmin-only because the trust decision gates OPC UA client access.",
|
||||||
|
[AdminRoles.FleetAdmin]),
|
||||||
|
new("Manage external-ID reservations",
|
||||||
|
"Reserve / release external IDs that map into Galaxy contained names.",
|
||||||
|
[AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
|
||||||
|
];
|
||||||
|
}
|
||||||
154
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor
Normal file
154
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
@page "/certificates"
|
||||||
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = AdminRoles.FleetAdmin)]
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@inject CertTrustService Certs
|
||||||
|
@inject AuthenticationStateProvider AuthState
|
||||||
|
@inject ILogger<Certificates> Log
|
||||||
|
|
||||||
|
<h1 class="mb-4">Certificate trust</h1>
|
||||||
|
|
||||||
|
<div class="alert alert-info small mb-4">
|
||||||
|
PKI store root <code>@Certs.PkiStoreRoot</code>. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake, so operators should retry the rejected client's connection after trusting.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_status is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-@_statusKind alert-dismissible">
|
||||||
|
@_status
|
||||||
|
<button type="button" class="btn-close" @onclick="ClearStatus"></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h2 class="h4">Rejected (@_rejected.Count)</h2>
|
||||||
|
@if (_rejected.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted">No rejected certificates. Clients that fail to handshake with an untrusted cert land here.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in _rejected)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@c.Subject</td>
|
||||||
|
<td>@c.Issuer</td>
|
||||||
|
<td><code class="small">@c.Thumbprint</code></td>
|
||||||
|
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-sm btn-success me-1" @onclick="() => TrustAsync(c)">Trust</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteRejectedAsync(c)">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h2 class="h4 mt-5">Trusted (@_trusted.Count)</h2>
|
||||||
|
@if (_trusted.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted">No client certs have been explicitly trusted. The server's own application cert lives in <code>own/</code> and is not listed here.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in _trusted)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@c.Subject</td>
|
||||||
|
<td>@c.Issuer</td>
|
||||||
|
<td><code class="small">@c.Thumbprint</code></td>
|
||||||
|
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => UntrustAsync(c)">Revoke</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private IReadOnlyList<CertInfo> _rejected = [];
|
||||||
|
private IReadOnlyList<CertInfo> _trusted = [];
|
||||||
|
private string? _status;
|
||||||
|
private string _statusKind = "success";
|
||||||
|
|
||||||
|
protected override void OnInitialized() => Reload();
|
||||||
|
|
||||||
|
private void Reload()
|
||||||
|
{
|
||||||
|
_rejected = Certs.ListRejected();
|
||||||
|
_trusted = Certs.ListTrusted();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TrustAsync(CertInfo c)
|
||||||
|
{
|
||||||
|
if (Certs.TrustRejected(c.Thumbprint))
|
||||||
|
{
|
||||||
|
await LogActionAsync("cert.trust", c);
|
||||||
|
Set($"Trusted cert {c.Subject} ({Short(c.Thumbprint)}).", "success");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Set($"Could not trust {Short(c.Thumbprint)} — file missing; another admin may have already handled it.", "warning");
|
||||||
|
}
|
||||||
|
Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteRejectedAsync(CertInfo c)
|
||||||
|
{
|
||||||
|
if (Certs.DeleteRejected(c.Thumbprint))
|
||||||
|
{
|
||||||
|
await LogActionAsync("cert.delete.rejected", c);
|
||||||
|
Set($"Deleted rejected cert {c.Subject} ({Short(c.Thumbprint)}).", "success");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Set($"Could not delete {Short(c.Thumbprint)} — file missing.", "warning");
|
||||||
|
}
|
||||||
|
Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UntrustAsync(CertInfo c)
|
||||||
|
{
|
||||||
|
if (Certs.UntrustCert(c.Thumbprint))
|
||||||
|
{
|
||||||
|
await LogActionAsync("cert.untrust", c);
|
||||||
|
Set($"Revoked trust for {c.Subject} ({Short(c.Thumbprint)}).", "success");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Set($"Could not revoke {Short(c.Thumbprint)} — file missing.", "warning");
|
||||||
|
}
|
||||||
|
Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LogActionAsync(string action, CertInfo c)
|
||||||
|
{
|
||||||
|
// Cert trust changes are operator-initiated and security-sensitive — Serilog captures the
|
||||||
|
// user + thumbprint trail. CertTrustService also logs at Information on each filesystem
|
||||||
|
// move/delete; this line ties the action to the authenticated admin user so the two logs
|
||||||
|
// correlate. DB-level ConfigAuditLog persistence is deferred — its schema is
|
||||||
|
// cluster-scoped and cert actions are cluster-agnostic.
|
||||||
|
var state = await AuthState.GetAuthenticationStateAsync();
|
||||||
|
var user = state.User.Identity?.Name ?? "(anonymous)";
|
||||||
|
Log.LogInformation("Admin cert action: user={User} action={Action} thumbprint={Thumbprint} subject={Subject}",
|
||||||
|
user, action, c.Thumbprint, c.Subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Set(string message, string kind)
|
||||||
|
{
|
||||||
|
_status = message;
|
||||||
|
_statusKind = kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearStatus() => _status = null;
|
||||||
|
|
||||||
|
private static string Short(string thumbprint) =>
|
||||||
|
thumbprint.Length > 12 ? thumbprint[..12] + "…" : thumbprint;
|
||||||
|
}
|
||||||
172
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor
Normal file
172
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
@page "/fleet"
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@inject IServiceScopeFactory ScopeFactory
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
<h1 class="mb-4">Fleet status</h1>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center mb-3 gap-2">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
|
||||||
|
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<span class="text-muted small">
|
||||||
|
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_rows is null)
|
||||||
|
{
|
||||||
|
<p>Loading…</p>
|
||||||
|
}
|
||||||
|
else if (_rows.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No node state recorded yet. Nodes publish their state to the central DB on each poll; if
|
||||||
|
this list is empty, either no nodes have been registered or the poller hasn't run yet.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card"><div class="card-body">
|
||||||
|
<h6 class="text-muted mb-1">Nodes</h6>
|
||||||
|
<div class="fs-3">@_rows.Count</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-success"><div class="card-body">
|
||||||
|
<h6 class="text-muted mb-1">Applied</h6>
|
||||||
|
<div class="fs-3 text-success">@_rows.Count(r => r.Status == "Applied")</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-warning"><div class="card-body">
|
||||||
|
<h6 class="text-muted mb-1">Stale</h6>
|
||||||
|
<div class="fs-3 text-warning">@_rows.Count(r => IsStale(r))</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-danger"><div class="card-body">
|
||||||
|
<h6 class="text-muted mb-1">Failed</h6>
|
||||||
|
<div class="fs-3 text-danger">@_rows.Count(r => r.Status == "Failed")</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Node</th>
|
||||||
|
<th>Cluster</th>
|
||||||
|
<th>Generation</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Last applied</th>
|
||||||
|
<th>Last seen</th>
|
||||||
|
<th>Error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in _rows)
|
||||||
|
{
|
||||||
|
<tr class="@RowClass(r)">
|
||||||
|
<td><code>@r.NodeId</code></td>
|
||||||
|
<td>@r.ClusterId</td>
|
||||||
|
<td>@(r.GenerationId?.ToString() ?? "—")</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
|
||||||
|
</td>
|
||||||
|
<td>@FormatAge(r.AppliedAt)</td>
|
||||||
|
<td class="@(IsStale(r) ? "text-warning" : "")">@FormatAge(r.SeenAt)</td>
|
||||||
|
<td class="text-truncate" style="max-width: 320px;" title="@r.Error">@r.Error</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// Refresh cadence. 5s matches FleetStatusPoller's poll interval — the dashboard always sees
|
||||||
|
// the most recent published state without polling ahead of the broadcaster.
|
||||||
|
private const int RefreshIntervalSeconds = 5;
|
||||||
|
|
||||||
|
private List<FleetNodeRow>? _rows;
|
||||||
|
private bool _refreshing;
|
||||||
|
private DateTime? _lastRefreshUtc;
|
||||||
|
private Timer? _timer;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await RefreshAsync();
|
||||||
|
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
|
||||||
|
state: null,
|
||||||
|
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
||||||
|
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
if (_refreshing) return;
|
||||||
|
_refreshing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = ScopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||||
|
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
|
||||||
|
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new FleetNodeRow(
|
||||||
|
s.NodeId, n.ClusterId, s.CurrentGenerationId,
|
||||||
|
s.LastAppliedStatus != null ? s.LastAppliedStatus.ToString() : null,
|
||||||
|
s.LastAppliedError, s.LastAppliedAt, s.LastSeenAt))
|
||||||
|
.OrderBy(r => r.ClusterId)
|
||||||
|
.ThenBy(r => r.NodeId)
|
||||||
|
.ToListAsync();
|
||||||
|
_rows = rows;
|
||||||
|
_lastRefreshUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_refreshing = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsStale(FleetNodeRow r)
|
||||||
|
{
|
||||||
|
if (r.SeenAt is null) return true;
|
||||||
|
return (DateTime.UtcNow - r.SeenAt.Value) > TimeSpan.FromSeconds(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RowClass(FleetNodeRow r) => r.Status switch
|
||||||
|
{
|
||||||
|
"Failed" => "table-danger",
|
||||||
|
_ when IsStale(r) => "table-warning",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string StatusBadge(string? status) => status switch
|
||||||
|
{
|
||||||
|
"Applied" => "bg-success",
|
||||||
|
"Failed" => "bg-danger",
|
||||||
|
"Applying" => "bg-info",
|
||||||
|
_ => "bg-secondary",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string FormatAge(DateTime? t)
|
||||||
|
{
|
||||||
|
if (t is null) return "—";
|
||||||
|
var age = DateTime.UtcNow - t.Value;
|
||||||
|
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||||
|
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||||
|
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
||||||
|
return t.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _timer?.Dispose();
|
||||||
|
|
||||||
|
internal sealed record FleetNodeRow(
|
||||||
|
string NodeId, string ClusterId, long? GenerationId,
|
||||||
|
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
|
||||||
|
}
|
||||||
@@ -48,6 +48,12 @@ builder.Services.AddScoped<ReservationService>();
|
|||||||
builder.Services.AddScoped<DraftValidationService>();
|
builder.Services.AddScoped<DraftValidationService>();
|
||||||
builder.Services.AddScoped<AuditLogService>();
|
builder.Services.AddScoped<AuditLogService>();
|
||||||
|
|
||||||
|
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
|
||||||
|
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
|
||||||
|
// filesystem operations.
|
||||||
|
builder.Services.Configure<CertTrustOptions>(builder.Configuration.GetSection(CertTrustOptions.SectionName));
|
||||||
|
builder.Services.AddSingleton<CertTrustService>();
|
||||||
|
|
||||||
// LDAP auth — parity with ScadaLink's LdapAuthService (decision #102).
|
// LDAP auth — parity with ScadaLink's LdapAuthService (decision #102).
|
||||||
builder.Services.Configure<LdapOptions>(
|
builder.Services.Configure<LdapOptions>(
|
||||||
builder.Configuration.GetSection("Authentication:Ldap"));
|
builder.Configuration.GetSection("Authentication:Ldap"));
|
||||||
|
|||||||
22
src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustOptions.cs
Normal file
22
src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustOptions.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Points the Admin UI at the OPC UA Server's PKI store root so
|
||||||
|
/// <see cref="CertTrustService"/> can list and move certs between the
|
||||||
|
/// <c>rejected/</c> and <c>trusted/</c> directories the server maintains. Must match the
|
||||||
|
/// <c>OpcUaServer:PkiStoreRoot</c> the Server process is configured with.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CertTrustOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "CertTrust";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Absolute path to the PKI root. Defaults to
|
||||||
|
/// <c>%ProgramData%\OtOpcUa\pki</c> — matches <c>OpcUaServerOptions.PkiStoreRoot</c>'s
|
||||||
|
/// default so a standard side-by-side install needs no override.
|
||||||
|
/// </summary>
|
||||||
|
public string PkiStoreRoot { get; init; } =
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||||
|
"OtOpcUa", "pki");
|
||||||
|
}
|
||||||
135
src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustService.cs
Normal file
135
src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustService.cs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Metadata for a certificate file found in one of the OPC UA server's PKI stores. The
|
||||||
|
/// <see cref="FilePath"/> is the absolute path of the DER/CRT file the stack created when it
|
||||||
|
/// rejected the cert (for <see cref="CertStoreKind.Rejected"/>) or when an operator trusted
|
||||||
|
/// it (for <see cref="CertStoreKind.Trusted"/>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CertInfo(
|
||||||
|
string Thumbprint,
|
||||||
|
string Subject,
|
||||||
|
string Issuer,
|
||||||
|
DateTime NotBefore,
|
||||||
|
DateTime NotAfter,
|
||||||
|
string FilePath,
|
||||||
|
CertStoreKind Store);
|
||||||
|
|
||||||
|
public enum CertStoreKind
|
||||||
|
{
|
||||||
|
Rejected,
|
||||||
|
Trusted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filesystem-backed view over the OPC UA server's PKI store. The Opc.Ua stack uses a
|
||||||
|
/// Directory-typed store — each cert is a <c>.der</c> file under <c>{root}/{store}/certs/</c>
|
||||||
|
/// with a filename derived from subject + thumbprint. This service exposes operators for the
|
||||||
|
/// Admin UI: list rejected, list trusted, trust a rejected cert (move to trusted), remove a
|
||||||
|
/// rejected cert (delete), untrust a previously trusted cert (delete from trusted).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The Admin process is separate from the Server process; this service deliberately has no
|
||||||
|
/// Opc.Ua dependency — it works on the on-disk layout directly so it can run on the Admin
|
||||||
|
/// host even when the Server isn't installed locally, as long as the PKI root is reachable
|
||||||
|
/// (typical deployment has Admin + Server side-by-side on the same machine).
|
||||||
|
///
|
||||||
|
/// Trust/untrust requires the Server to re-read its trust list. The Opc.Ua stack re-reads
|
||||||
|
/// the Directory store on each new incoming connection, so there's no explicit signal
|
||||||
|
/// needed — the next client handshake picks up the change. Operators should retry the
|
||||||
|
/// rejected client's connection after trusting.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CertTrustService
|
||||||
|
{
|
||||||
|
private readonly CertTrustOptions _options;
|
||||||
|
private readonly ILogger<CertTrustService> _logger;
|
||||||
|
|
||||||
|
public CertTrustService(IOptions<CertTrustOptions> options, ILogger<CertTrustService> logger)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PkiStoreRoot => _options.PkiStoreRoot;
|
||||||
|
|
||||||
|
public IReadOnlyList<CertInfo> ListRejected() => ListStore(CertStoreKind.Rejected);
|
||||||
|
public IReadOnlyList<CertInfo> ListTrusted() => ListStore(CertStoreKind.Trusted);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Move the cert with <paramref name="thumbprint"/> from the rejected store to the
|
||||||
|
/// trusted store. No-op returns false if the rejected file doesn't exist (already moved
|
||||||
|
/// by another operator, or thumbprint mismatch). Overwrites an existing trusted copy
|
||||||
|
/// silently — idempotent.
|
||||||
|
/// </summary>
|
||||||
|
public bool TrustRejected(string thumbprint)
|
||||||
|
{
|
||||||
|
var cert = FindInStore(CertStoreKind.Rejected, thumbprint);
|
||||||
|
if (cert is null) return false;
|
||||||
|
|
||||||
|
var trustedDir = CertsDir(CertStoreKind.Trusted);
|
||||||
|
Directory.CreateDirectory(trustedDir);
|
||||||
|
var destPath = Path.Combine(trustedDir, Path.GetFileName(cert.FilePath));
|
||||||
|
File.Move(cert.FilePath, destPath, overwrite: true);
|
||||||
|
_logger.LogInformation("Trusted cert {Thumbprint} (subject={Subject}) — moved {From} → {To}",
|
||||||
|
cert.Thumbprint, cert.Subject, cert.FilePath, destPath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DeleteRejected(string thumbprint) => DeleteFromStore(CertStoreKind.Rejected, thumbprint);
|
||||||
|
public bool UntrustCert(string thumbprint) => DeleteFromStore(CertStoreKind.Trusted, thumbprint);
|
||||||
|
|
||||||
|
private bool DeleteFromStore(CertStoreKind store, string thumbprint)
|
||||||
|
{
|
||||||
|
var cert = FindInStore(store, thumbprint);
|
||||||
|
if (cert is null) return false;
|
||||||
|
File.Delete(cert.FilePath);
|
||||||
|
_logger.LogInformation("Deleted cert {Thumbprint} (subject={Subject}) from {Store} store",
|
||||||
|
cert.Thumbprint, cert.Subject, store);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CertInfo? FindInStore(CertStoreKind store, string thumbprint) =>
|
||||||
|
ListStore(store).FirstOrDefault(c =>
|
||||||
|
string.Equals(c.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private IReadOnlyList<CertInfo> ListStore(CertStoreKind store)
|
||||||
|
{
|
||||||
|
var dir = CertsDir(store);
|
||||||
|
if (!Directory.Exists(dir)) return [];
|
||||||
|
|
||||||
|
var results = new List<CertInfo>();
|
||||||
|
foreach (var path in Directory.EnumerateFiles(dir))
|
||||||
|
{
|
||||||
|
// Skip CRL sidecars + private-key files — trust operations only concern public certs.
|
||||||
|
var ext = Path.GetExtension(path);
|
||||||
|
if (!ext.Equals(".der", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!ext.Equals(".crt", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!ext.Equals(".cer", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cert = X509CertificateLoader.LoadCertificateFromFile(path);
|
||||||
|
results.Add(new CertInfo(
|
||||||
|
cert.Thumbprint, cert.Subject, cert.Issuer,
|
||||||
|
cert.NotBefore.ToUniversalTime(), cert.NotAfter.ToUniversalTime(),
|
||||||
|
path, store));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A malformed file in the store shouldn't take down the page. Surface it in logs
|
||||||
|
// but skip — operators see the other certs and can clean the bad file manually.
|
||||||
|
_logger.LogWarning(ex, "Failed to parse cert at {Path} — skipping", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CertsDir(CertStoreKind store) =>
|
||||||
|
Path.Combine(_options.PkiStoreRoot, store == CertStoreKind.Rejected ? "rejected" : "trusted", "certs");
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-host connectivity snapshot the Server publishes for each driver's
|
||||||
|
/// <c>IHostConnectivityProbe.GetHostStatuses</c> entry. One row per
|
||||||
|
/// (<see cref="NodeId"/>, <see cref="DriverInstanceId"/>, <see cref="HostName"/>) triple —
|
||||||
|
/// a redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces 6
|
||||||
|
/// rows, not 3, because each server node owns its own runtime view.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Closes the data-layer piece of LMX follow-up #7 (per-AppEngine Admin dashboard
|
||||||
|
/// drill-down). The publisher hosted service on the Server side subscribes to every
|
||||||
|
/// registered driver's <c>OnHostStatusChanged</c> and upserts rows on transitions +
|
||||||
|
/// periodic liveness heartbeats. <see cref="LastSeenUtc"/> advances on every
|
||||||
|
/// heartbeat so the Admin UI can flag stale rows from a crashed Server.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// No foreign-key to <see cref="ClusterNode"/> — a Server may start reporting host
|
||||||
|
/// status before its ClusterNode row exists (e.g. first-boot bootstrap), and we'd
|
||||||
|
/// rather keep the status row than drop it. The Admin-side service left-joins on
|
||||||
|
/// NodeId when presenting rows.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class DriverHostStatus
|
||||||
|
{
|
||||||
|
/// <summary>Server node that's running the driver.</summary>
|
||||||
|
public required string NodeId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Driver instance's stable id (matches <c>IDriver.DriverInstanceId</c>).</summary>
|
||||||
|
public required string DriverInstanceId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver-side host identifier — Galaxy Platform / AppEngine name, Modbus
|
||||||
|
/// <c>host:port</c>, whatever the probe returns. Opaque to the Admin UI except as
|
||||||
|
/// a display string.
|
||||||
|
/// </summary>
|
||||||
|
public required string HostName { get; set; }
|
||||||
|
|
||||||
|
public DriverHostState State { get; set; } = DriverHostState.Unknown;
|
||||||
|
|
||||||
|
/// <summary>Timestamp of the last state transition (not of the most recent heartbeat).</summary>
|
||||||
|
public DateTime StateChangedUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances on every publisher heartbeat — the Admin UI uses
|
||||||
|
/// <c>now - LastSeenUtc > threshold</c> to flag rows whose owning Server has
|
||||||
|
/// stopped reporting (crashed, network-partitioned, etc.), independent of
|
||||||
|
/// <see cref="State"/>.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime LastSeenUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional human-readable detail populated when <see cref="State"/> is
|
||||||
|
/// <see cref="DriverHostState.Faulted"/> — e.g. the exception message from the
|
||||||
|
/// driver's probe. Null for Running / Stopped / Unknown transitions.
|
||||||
|
/// </summary>
|
||||||
|
public string? Detail { get; set; }
|
||||||
|
}
|
||||||
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DriverHostState.cs
Normal file
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DriverHostState.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persisted mirror of <c>Core.Abstractions.HostState</c> — the lifecycle state each
|
||||||
|
/// <c>IHostConnectivityProbe</c>-capable driver reports for its per-host topology
|
||||||
|
/// (Galaxy Platforms / AppEngines, Modbus PLC endpoints, future OPC UA gateway upstreams).
|
||||||
|
/// Defined here instead of re-using <c>Core.Abstractions.HostState</c> so the
|
||||||
|
/// Configuration project stays free of driver-runtime dependencies.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The server-side publisher (follow-up PR) translates
|
||||||
|
/// <c>HostStatusChangedEventArgs.NewState</c> to this enum on every transition and
|
||||||
|
/// upserts into <see cref="Entities.DriverHostStatus"/>. Admin UI reads from the DB.
|
||||||
|
/// </remarks>
|
||||||
|
public enum DriverHostState
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Running,
|
||||||
|
Stopped,
|
||||||
|
Faulted,
|
||||||
|
}
|
||||||
1248
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260418193608_AddDriverHostStatus.Designer.cs
generated
Normal file
1248
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260418193608_AddDriverHostStatus.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDriverHostStatus : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DriverHostStatus",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
HostName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||||
|
State = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||||
|
StateChangedUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
|
||||||
|
LastSeenUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
|
||||||
|
Detail = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DriverHostStatus", x => new { x.NodeId, x.DriverInstanceId, x.HostName });
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DriverHostStatus_LastSeen",
|
||||||
|
table: "DriverHostStatus",
|
||||||
|
column: "LastSeenUtc");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DriverHostStatus_Node",
|
||||||
|
table: "DriverHostStatus",
|
||||||
|
column: "NodeId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DriverHostStatus");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -332,6 +332,46 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("NodeId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("DriverInstanceId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("HostName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Detail")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastSeenUtc")
|
||||||
|
.HasColumnType("datetime2(3)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("nvarchar(16)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("StateChangedUtc")
|
||||||
|
.HasColumnType("datetime2(3)");
|
||||||
|
|
||||||
|
b.HasKey("NodeId", "DriverInstanceId", "HostName");
|
||||||
|
|
||||||
|
b.HasIndex("LastSeenUtc")
|
||||||
|
.HasDatabaseName("IX_DriverHostStatus_LastSeen");
|
||||||
|
|
||||||
|
b.HasIndex("NodeId")
|
||||||
|
.HasDatabaseName("IX_DriverHostStatus_Node");
|
||||||
|
|
||||||
|
b.ToTable("DriverHostStatus", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b =>
|
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("DriverInstanceRowId")
|
b.Property<Guid>("DriverInstanceRowId")
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
|||||||
public DbSet<ClusterNodeGenerationState> ClusterNodeGenerationStates => Set<ClusterNodeGenerationState>();
|
public DbSet<ClusterNodeGenerationState> ClusterNodeGenerationStates => Set<ClusterNodeGenerationState>();
|
||||||
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
|
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
|
||||||
public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
|
public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
|
||||||
|
public DbSet<DriverHostStatus> DriverHostStatuses => Set<DriverHostStatus>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -47,6 +48,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
|||||||
ConfigureClusterNodeGenerationState(modelBuilder);
|
ConfigureClusterNodeGenerationState(modelBuilder);
|
||||||
ConfigureConfigAuditLog(modelBuilder);
|
ConfigureConfigAuditLog(modelBuilder);
|
||||||
ConfigureExternalIdReservation(modelBuilder);
|
ConfigureExternalIdReservation(modelBuilder);
|
||||||
|
ConfigureDriverHostStatus(modelBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
|
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
|
||||||
@@ -484,4 +486,30 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
|||||||
e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment");
|
e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ConfigureDriverHostStatus(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<DriverHostStatus>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("DriverHostStatus");
|
||||||
|
// Composite key — one row per (server node, driver instance, probe-reported host).
|
||||||
|
// A redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces
|
||||||
|
// 6 rows because each server node owns its own runtime view; the composite key is
|
||||||
|
// what lets both views coexist without shadowing each other.
|
||||||
|
e.HasKey(x => new { x.NodeId, x.DriverInstanceId, x.HostName });
|
||||||
|
e.Property(x => x.NodeId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.HostName).HasMaxLength(256);
|
||||||
|
e.Property(x => x.State).HasConversion<string>().HasMaxLength(16);
|
||||||
|
e.Property(x => x.StateChangedUtc).HasColumnType("datetime2(3)");
|
||||||
|
e.Property(x => x.LastSeenUtc).HasColumnType("datetime2(3)");
|
||||||
|
e.Property(x => x.Detail).HasMaxLength(1024);
|
||||||
|
|
||||||
|
// NodeId-only index drives the Admin UI's per-cluster drill-down (select all host
|
||||||
|
// statuses for the nodes of a specific cluster via join on ClusterNode.ClusterId).
|
||||||
|
e.HasIndex(x => x.NodeId).HasDatabaseName("IX_DriverHostStatus_Node");
|
||||||
|
// LastSeenUtc index powers the Admin UI's stale-row query (now - LastSeen > N).
|
||||||
|
e.HasIndex(x => x.LastSeenUtc).HasDatabaseName("IX_DriverHostStatus_LastSeen");
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs
Normal file
25
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstraction over the Modbus TCP socket. Takes a <c>PDU</c> (function code + data, excluding
|
||||||
|
/// the 7-byte MBAP header) and returns the response PDU — the transport owns transaction-id
|
||||||
|
/// pairing, framing, and socket I/O. Tests supply in-memory fakes.
|
||||||
|
/// </summary>
|
||||||
|
public interface IModbusTransport : IAsyncDisposable
|
||||||
|
{
|
||||||
|
Task ConnectAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send a Modbus PDU (function code + function-specific data) and read the response PDU.
|
||||||
|
/// Throws <see cref="ModbusException"/> when the server returns an exception PDU
|
||||||
|
/// (function code + 0x80 + exception code).
|
||||||
|
/// </summary>
|
||||||
|
Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ModbusException(byte functionCode, byte exceptionCode, string message)
|
||||||
|
: Exception(message)
|
||||||
|
{
|
||||||
|
public byte FunctionCode { get; } = functionCode;
|
||||||
|
public byte ExceptionCode { get; } = exceptionCode;
|
||||||
|
}
|
||||||
583
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
Normal file
583
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modbus TCP implementation of <see cref="IDriver"/> + <see cref="ITagDiscovery"/> +
|
||||||
|
/// <see cref="IReadable"/> + <see cref="IWritable"/>. First native-protocol greenfield
|
||||||
|
/// driver for the v2 stack — validates the driver-agnostic <c>IAddressSpaceBuilder</c> +
|
||||||
|
/// <c>IReadable</c>/<c>IWritable</c> abstractions generalize beyond Galaxy.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Scope limits: synchronous Read/Write only, no subscriptions (Modbus has no push model;
|
||||||
|
/// subscriptions would need a polling loop over the declared tags — additive PR). Historian
|
||||||
|
/// + alarm capabilities are out of scope (the protocol doesn't express them).
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
|
||||||
|
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null)
|
||||||
|
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
|
||||||
|
{
|
||||||
|
// Active polling subscriptions. Each subscription owns a background Task that polls the
|
||||||
|
// tags at its configured interval, diffs against _lastKnownValues, and fires OnDataChange
|
||||||
|
// per changed tag. UnsubscribeAsync cancels the task via the CTS stored on the handle.
|
||||||
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
|
||||||
|
private long _nextSubscriptionId;
|
||||||
|
|
||||||
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||||
|
|
||||||
|
// Single-host probe state — Modbus driver talks to exactly one endpoint so the "hosts"
|
||||||
|
// collection has at most one entry. HostName is the Host:Port string so the Admin UI can
|
||||||
|
// display the PLC endpoint uniformly with Galaxy platforms/engines.
|
||||||
|
private readonly object _probeLock = new();
|
||||||
|
private HostState _hostState = HostState.Unknown;
|
||||||
|
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
||||||
|
private CancellationTokenSource? _probeCts;
|
||||||
|
private readonly ModbusDriverOptions _options = options;
|
||||||
|
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory =
|
||||||
|
transportFactory ?? (o => new ModbusTcpTransport(o.Host, o.Port, o.Timeout));
|
||||||
|
|
||||||
|
private IModbusTransport? _transport;
|
||||||
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
private readonly Dictionary<string, ModbusTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public string DriverInstanceId => driverInstanceId;
|
||||||
|
public string DriverType => "Modbus";
|
||||||
|
|
||||||
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_transport = _transportFactory(_options);
|
||||||
|
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
foreach (var t in _options.Tags) _tagsByName[t.Name] = t;
|
||||||
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
|
|
||||||
|
// PR 23: kick off the probe loop once the transport is up. Initial state stays
|
||||||
|
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
|
||||||
|
// Running transition before any register round-trip has happened.
|
||||||
|
if (_options.Probe.Enabled)
|
||||||
|
{
|
||||||
|
_probeCts = new CancellationTokenSource();
|
||||||
|
_ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await ShutdownAsync(cancellationToken);
|
||||||
|
await InitializeAsync(driverConfigJson, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try { _probeCts?.Cancel(); } catch { }
|
||||||
|
_probeCts?.Dispose();
|
||||||
|
_probeCts = null;
|
||||||
|
|
||||||
|
foreach (var state in _subscriptions.Values)
|
||||||
|
{
|
||||||
|
try { state.Cts.Cancel(); } catch { }
|
||||||
|
state.Cts.Dispose();
|
||||||
|
}
|
||||||
|
_subscriptions.Clear();
|
||||||
|
|
||||||
|
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
|
||||||
|
_transport = null;
|
||||||
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DriverHealth GetHealth() => _health;
|
||||||
|
public long GetMemoryFootprint() => 0;
|
||||||
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
// ---- ITagDiscovery ----
|
||||||
|
|
||||||
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(builder);
|
||||||
|
var folder = builder.Folder("Modbus", "Modbus");
|
||||||
|
foreach (var t in _options.Tags)
|
||||||
|
{
|
||||||
|
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
||||||
|
FullName: t.Name,
|
||||||
|
DriverDataType: MapDataType(t.DataType),
|
||||||
|
IsArray: false,
|
||||||
|
ArrayDim: null,
|
||||||
|
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
|
||||||
|
IsHistorized: false,
|
||||||
|
IsAlarm: false));
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IReadable ----
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||||
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var transport = RequireTransport();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var results = new DataValueSnapshot[fullReferences.Count];
|
||||||
|
for (var i = 0; i < fullReferences.Count; i++)
|
||||||
|
{
|
||||||
|
if (!_tagsByName.TryGetValue(fullReferences[i], out var tag))
|
||||||
|
{
|
||||||
|
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false);
|
||||||
|
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
||||||
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
results[i] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<object> ReadOneAsync(IModbusTransport transport, ModbusTagDefinition tag, CancellationToken ct)
|
||||||
|
{
|
||||||
|
switch (tag.Region)
|
||||||
|
{
|
||||||
|
case ModbusRegion.Coils:
|
||||||
|
{
|
||||||
|
var pdu = new byte[] { 0x01, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
|
||||||
|
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||||
|
return (resp[2] & 0x01) == 1;
|
||||||
|
}
|
||||||
|
case ModbusRegion.DiscreteInputs:
|
||||||
|
{
|
||||||
|
var pdu = new byte[] { 0x02, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
|
||||||
|
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||||
|
return (resp[2] & 0x01) == 1;
|
||||||
|
}
|
||||||
|
case ModbusRegion.HoldingRegisters:
|
||||||
|
case ModbusRegion.InputRegisters:
|
||||||
|
{
|
||||||
|
var quantity = RegisterCount(tag);
|
||||||
|
var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
||||||
|
var pdu = new byte[] { fc, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||||
|
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
|
||||||
|
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||||
|
// resp = [fc][byte-count][data...]
|
||||||
|
var data = new ReadOnlySpan<byte>(resp, 2, resp[1]);
|
||||||
|
return DecodeRegister(data, tag);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"Unknown region {tag.Region}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IWritable ----
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||||
|
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var transport = RequireTransport();
|
||||||
|
var results = new WriteResult[writes.Count];
|
||||||
|
for (var i = 0; i < writes.Count; i++)
|
||||||
|
{
|
||||||
|
var w = writes[i];
|
||||||
|
if (!_tagsByName.TryGetValue(w.FullReference, out var tag))
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(StatusBadNodeIdUnknown);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!tag.Writable || tag.Region is ModbusRegion.DiscreteInputs or ModbusRegion.InputRegisters)
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(StatusBadNotWritable);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false);
|
||||||
|
results[i] = new WriteResult(0u);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(StatusBadInternalError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteOneAsync(IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
|
||||||
|
{
|
||||||
|
switch (tag.Region)
|
||||||
|
{
|
||||||
|
case ModbusRegion.Coils:
|
||||||
|
{
|
||||||
|
var on = Convert.ToBoolean(value);
|
||||||
|
var pdu = new byte[] { 0x05, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||||
|
on ? (byte)0xFF : (byte)0x00, 0x00 };
|
||||||
|
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case ModbusRegion.HoldingRegisters:
|
||||||
|
{
|
||||||
|
var bytes = EncodeRegister(value, tag);
|
||||||
|
if (bytes.Length == 2)
|
||||||
|
{
|
||||||
|
var pdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||||
|
bytes[0], bytes[1] };
|
||||||
|
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// FC 16 (Write Multiple Registers) for 32-bit types
|
||||||
|
var qty = (ushort)(bytes.Length / 2);
|
||||||
|
var pdu = new byte[6 + 1 + bytes.Length];
|
||||||
|
pdu[0] = 0x10;
|
||||||
|
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
|
||||||
|
pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF);
|
||||||
|
pdu[5] = (byte)bytes.Length;
|
||||||
|
Buffer.BlockCopy(bytes, 0, pdu, 6, bytes.Length);
|
||||||
|
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"Writes not supported for region {tag.Region}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ISubscribable (polling overlay) ----
|
||||||
|
|
||||||
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||||
|
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var id = Interlocked.Increment(ref _nextSubscriptionId);
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
var interval = publishingInterval < TimeSpan.FromMilliseconds(100)
|
||||||
|
? TimeSpan.FromMilliseconds(100) // floor — Modbus can't sustain < 100ms polling reliably
|
||||||
|
: publishingInterval;
|
||||||
|
var handle = new ModbusSubscriptionHandle(id);
|
||||||
|
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
|
||||||
|
_subscriptions[id] = state;
|
||||||
|
_ = Task.Run(() => PollLoopAsync(state, cts.Token), cts.Token);
|
||||||
|
return Task.FromResult<ISubscriptionHandle>(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (handle is ModbusSubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
|
||||||
|
{
|
||||||
|
state.Cts.Cancel();
|
||||||
|
state.Cts.Dispose();
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Initial-data push: read every tag once at subscribe time so OPC UA clients see the
|
||||||
|
// current value per Part 4 convention, even if the value never changes thereafter.
|
||||||
|
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
|
||||||
|
catch (OperationCanceledException) { return; }
|
||||||
|
catch { /* first-read error — polling continues */ }
|
||||||
|
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
|
||||||
|
catch (OperationCanceledException) { return; }
|
||||||
|
|
||||||
|
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
|
||||||
|
catch (OperationCanceledException) { return; }
|
||||||
|
catch { /* transient polling error — loop continues, health surface reflects it */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var snapshots = await ReadAsync(state.TagReferences, ct).ConfigureAwait(false);
|
||||||
|
for (var i = 0; i < state.TagReferences.Count; i++)
|
||||||
|
{
|
||||||
|
var tagRef = state.TagReferences[i];
|
||||||
|
var current = snapshots[i];
|
||||||
|
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
|
||||||
|
|
||||||
|
// Raise on first read (forceRaise) OR when the boxed value differs from last-known.
|
||||||
|
if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
|
||||||
|
{
|
||||||
|
state.LastValues[tagRef] = current;
|
||||||
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(state.Handle, tagRef, current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record SubscriptionState(
|
||||||
|
ModbusSubscriptionHandle Handle,
|
||||||
|
IReadOnlyList<string> TagReferences,
|
||||||
|
TimeSpan Interval,
|
||||||
|
CancellationTokenSource Cts)
|
||||||
|
{
|
||||||
|
public System.Collections.Concurrent.ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
|
||||||
|
= new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ModbusSubscriptionHandle(long Id) : ISubscriptionHandle
|
||||||
|
{
|
||||||
|
public string DiagnosticId => $"modbus-sub-{Id}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IHostConnectivityProbe ----
|
||||||
|
|
||||||
|
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
|
||||||
|
{
|
||||||
|
lock (_probeLock)
|
||||||
|
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host identifier surfaced to <c>IHostConnectivityProbe.GetHostStatuses</c> and the Admin UI.
|
||||||
|
/// Formatted as <c>host:port</c> so multiple Modbus drivers in the same server disambiguate
|
||||||
|
/// by endpoint without needing the driver-instance-id in the Admin dashboard.
|
||||||
|
/// </summary>
|
||||||
|
public string HostName => $"{_options.Host}:{_options.Port}";
|
||||||
|
|
||||||
|
private async Task ProbeLoopAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var transport = _transport; // captured reference; disposal tears the loop down via ct
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var success = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
probeCts.CancelAfter(_options.Probe.Timeout);
|
||||||
|
var pdu = new byte[] { 0x03,
|
||||||
|
(byte)(_options.Probe.ProbeAddress >> 8),
|
||||||
|
(byte)(_options.Probe.ProbeAddress & 0xFF), 0x00, 0x01 };
|
||||||
|
_ = await transport!.SendAsync(_options.UnitId, pdu, probeCts.Token).ConfigureAwait(false);
|
||||||
|
success = true;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// transport / timeout / exception PDU — treated as Stopped below
|
||||||
|
}
|
||||||
|
|
||||||
|
TransitionTo(success ? HostState.Running : HostState.Stopped);
|
||||||
|
|
||||||
|
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||||
|
catch (OperationCanceledException) { return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TransitionTo(HostState newState)
|
||||||
|
{
|
||||||
|
HostState old;
|
||||||
|
lock (_probeLock)
|
||||||
|
{
|
||||||
|
old = _hostState;
|
||||||
|
if (old == newState) return;
|
||||||
|
_hostState = newState;
|
||||||
|
_hostStateChangedUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- codec ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many 16-bit registers a given tag occupies. Accounts for multi-register logical
|
||||||
|
/// types (Int32/Float32 = 2 regs, Int64/Float64 = 4 regs) and for strings (rounded up
|
||||||
|
/// from 2 chars per register).
|
||||||
|
/// </summary>
|
||||||
|
internal static ushort RegisterCount(ModbusTagDefinition tag) => tag.DataType switch
|
||||||
|
{
|
||||||
|
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister => 1,
|
||||||
|
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 => 2,
|
||||||
|
ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 4,
|
||||||
|
ModbusDataType.String => (ushort)((tag.StringLength + 1) / 2), // 2 chars per register
|
||||||
|
_ => throw new InvalidOperationException($"Non-register data type {tag.DataType}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Word-swap the input into the big-endian layout the decoders expect. For 2-register
|
||||||
|
/// types this reverses the two words; for 4-register types it reverses the four words
|
||||||
|
/// (PLC stored [hi-mid, low-mid, hi-high, low-high] → memory [hi-high, low-high, hi-mid, low-mid]).
|
||||||
|
/// </summary>
|
||||||
|
private static byte[] NormalizeWordOrder(ReadOnlySpan<byte> data, ModbusByteOrder order)
|
||||||
|
{
|
||||||
|
if (order == ModbusByteOrder.BigEndian) return data.ToArray();
|
||||||
|
var result = new byte[data.Length];
|
||||||
|
for (var word = 0; word < data.Length / 2; word++)
|
||||||
|
{
|
||||||
|
var srcWord = data.Length / 2 - 1 - word;
|
||||||
|
result[word * 2] = data[srcWord * 2];
|
||||||
|
result[word * 2 + 1] = data[srcWord * 2 + 1];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static object DecodeRegister(ReadOnlySpan<byte> data, ModbusTagDefinition tag)
|
||||||
|
{
|
||||||
|
switch (tag.DataType)
|
||||||
|
{
|
||||||
|
case ModbusDataType.Int16: return BinaryPrimitives.ReadInt16BigEndian(data);
|
||||||
|
case ModbusDataType.UInt16: return BinaryPrimitives.ReadUInt16BigEndian(data);
|
||||||
|
case ModbusDataType.BitInRegister:
|
||||||
|
{
|
||||||
|
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
|
||||||
|
return (raw & (1 << tag.BitIndex)) != 0;
|
||||||
|
}
|
||||||
|
case ModbusDataType.Int32:
|
||||||
|
{
|
||||||
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||||
|
return BinaryPrimitives.ReadInt32BigEndian(b);
|
||||||
|
}
|
||||||
|
case ModbusDataType.UInt32:
|
||||||
|
{
|
||||||
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||||
|
return BinaryPrimitives.ReadUInt32BigEndian(b);
|
||||||
|
}
|
||||||
|
case ModbusDataType.Float32:
|
||||||
|
{
|
||||||
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||||
|
return BinaryPrimitives.ReadSingleBigEndian(b);
|
||||||
|
}
|
||||||
|
case ModbusDataType.Int64:
|
||||||
|
{
|
||||||
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||||
|
return BinaryPrimitives.ReadInt64BigEndian(b);
|
||||||
|
}
|
||||||
|
case ModbusDataType.UInt64:
|
||||||
|
{
|
||||||
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||||
|
return BinaryPrimitives.ReadUInt64BigEndian(b);
|
||||||
|
}
|
||||||
|
case ModbusDataType.Float64:
|
||||||
|
{
|
||||||
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||||
|
return BinaryPrimitives.ReadDoubleBigEndian(b);
|
||||||
|
}
|
||||||
|
case ModbusDataType.String:
|
||||||
|
{
|
||||||
|
// ASCII, 2 chars per register, packed high byte = first char.
|
||||||
|
// Respect the caller's StringLength (truncate nul-padded regions).
|
||||||
|
var chars = new char[tag.StringLength];
|
||||||
|
for (var i = 0; i < tag.StringLength; i++)
|
||||||
|
{
|
||||||
|
var b = data[i];
|
||||||
|
if (b == 0) { return new string(chars, 0, i); }
|
||||||
|
chars[i] = (char)b;
|
||||||
|
}
|
||||||
|
return new string(chars);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static byte[] EncodeRegister(object? value, ModbusTagDefinition tag)
|
||||||
|
{
|
||||||
|
switch (tag.DataType)
|
||||||
|
{
|
||||||
|
case ModbusDataType.Int16:
|
||||||
|
{
|
||||||
|
var v = Convert.ToInt16(value);
|
||||||
|
var b = new byte[2]; BinaryPrimitives.WriteInt16BigEndian(b, v); return b;
|
||||||
|
}
|
||||||
|
case ModbusDataType.UInt16:
|
||||||
|
{
|
||||||
|
var v = Convert.ToUInt16(value);
|
||||||
|
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); return b;
|
||||||
|
}
|
||||||
|
case ModbusDataType.Int32:
|
||||||
|
{
|
||||||
|
var v = Convert.ToInt32(value);
|
||||||
|
var b = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(b, v);
|
||||||
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||||
|
}
|
||||||
|
case ModbusDataType.UInt32:
|
||||||
|
{
|
||||||
|
var v = Convert.ToUInt32(value);
|
||||||
|
var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v);
|
||||||
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||||
|
}
|
||||||
|
case ModbusDataType.Float32:
|
||||||
|
{
|
||||||
|
var v = Convert.ToSingle(value);
|
||||||
|
var b = new byte[4]; BinaryPrimitives.WriteSingleBigEndian(b, v);
|
||||||
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||||
|
}
|
||||||
|
case ModbusDataType.Int64:
|
||||||
|
{
|
||||||
|
var v = Convert.ToInt64(value);
|
||||||
|
var b = new byte[8]; BinaryPrimitives.WriteInt64BigEndian(b, v);
|
||||||
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||||
|
}
|
||||||
|
case ModbusDataType.UInt64:
|
||||||
|
{
|
||||||
|
var v = Convert.ToUInt64(value);
|
||||||
|
var b = new byte[8]; BinaryPrimitives.WriteUInt64BigEndian(b, v);
|
||||||
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||||
|
}
|
||||||
|
case ModbusDataType.Float64:
|
||||||
|
{
|
||||||
|
var v = Convert.ToDouble(value);
|
||||||
|
var b = new byte[8]; BinaryPrimitives.WriteDoubleBigEndian(b, v);
|
||||||
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||||
|
}
|
||||||
|
case ModbusDataType.String:
|
||||||
|
{
|
||||||
|
var s = Convert.ToString(value) ?? string.Empty;
|
||||||
|
var regs = (tag.StringLength + 1) / 2;
|
||||||
|
var b = new byte[regs * 2];
|
||||||
|
for (var i = 0; i < tag.StringLength && i < s.Length; i++) b[i] = (byte)s[i];
|
||||||
|
// remaining bytes stay 0 — nul-padded per PLC convention
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
case ModbusDataType.BitInRegister:
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"BitInRegister writes require a read-modify-write; not supported in PR 24 (separate follow-up).");
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DriverDataType MapDataType(ModbusDataType t) => t switch
|
||||||
|
{
|
||||||
|
ModbusDataType.Bool or ModbusDataType.BitInRegister => DriverDataType.Boolean,
|
||||||
|
ModbusDataType.Int16 or ModbusDataType.Int32 => DriverDataType.Int32,
|
||||||
|
ModbusDataType.UInt16 or ModbusDataType.UInt32 => DriverDataType.Int32,
|
||||||
|
ModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32, // widening to Int32 loses precision; PR 25 adds Int64 to DriverDataType
|
||||||
|
ModbusDataType.Float32 => DriverDataType.Float32,
|
||||||
|
ModbusDataType.Float64 => DriverDataType.Float64,
|
||||||
|
ModbusDataType.String => DriverDataType.String,
|
||||||
|
_ => DriverDataType.Int32,
|
||||||
|
};
|
||||||
|
|
||||||
|
private IModbusTransport RequireTransport() =>
|
||||||
|
_transport ?? throw new InvalidOperationException("ModbusDriver not initialized");
|
||||||
|
|
||||||
|
private const uint StatusBadInternalError = 0x80020000u;
|
||||||
|
private const uint StatusBadNodeIdUnknown = 0x80340000u;
|
||||||
|
private const uint StatusBadNotWritable = 0x803B0000u;
|
||||||
|
|
||||||
|
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
|
||||||
|
_transport = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs
Normal file
97
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modbus TCP driver configuration. Bound from the driver's <c>DriverConfig</c> JSON at
|
||||||
|
/// <c>DriverHost.RegisterAsync</c>. Every register the driver exposes appears in
|
||||||
|
/// <see cref="Tags"/>; names become the OPC UA browse name + full reference.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ModbusDriverOptions
|
||||||
|
{
|
||||||
|
public string Host { get; init; } = "127.0.0.1";
|
||||||
|
public int Port { get; init; } = 502;
|
||||||
|
public byte UnitId { get; init; } = 1;
|
||||||
|
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
/// <summary>Pre-declared tag map. Modbus has no discovery protocol — the driver returns exactly these.</summary>
|
||||||
|
public IReadOnlyList<ModbusTagDefinition> Tags { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background connectivity-probe settings. When <see cref="ModbusProbeOptions.Enabled"/>
|
||||||
|
/// is true the driver runs a tick loop that issues a cheap FC03 at register 0 every
|
||||||
|
/// <see cref="ModbusProbeOptions.Interval"/> and raises <c>OnHostStatusChanged</c> on
|
||||||
|
/// Running ↔ Stopped transitions. The Admin UI / OPC UA clients see the state through
|
||||||
|
/// <see cref="IHostConnectivityProbe"/>.
|
||||||
|
/// </summary>
|
||||||
|
public ModbusProbeOptions Probe { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ModbusProbeOptions
|
||||||
|
{
|
||||||
|
public bool Enabled { get; init; } = true;
|
||||||
|
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||||||
|
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||||
|
/// <summary>Register to read for the probe. Zero is usually safe; override for PLCs that lock register 0.</summary>
|
||||||
|
public ushort ProbeAddress { get; init; } = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One Modbus-backed OPC UA variable. Address is zero-based (Modbus spec numbering, not
|
||||||
|
/// the documentation's 1-based coil/register conventions). Multi-register types
|
||||||
|
/// (Int32/UInt32/Float32 = 2 regs; Int64/UInt64/Float64 = 4 regs) respect the
|
||||||
|
/// <see cref="ByteOrder"/> field — real-world PLCs disagree on word ordering.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Name">
|
||||||
|
/// Tag name, used for both the OPC UA browse name and the driver's full reference. Must be
|
||||||
|
/// unique within the driver.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Region">Coils / DiscreteInputs / InputRegisters / HoldingRegisters.</param>
|
||||||
|
/// <param name="Address">Zero-based address within the region.</param>
|
||||||
|
/// <param name="DataType">
|
||||||
|
/// Logical data type. See <see cref="ModbusDataType"/> for the register count each encodes.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Writable">When true and Region supports writes (Coils / HoldingRegisters), IWritable routes writes here.</param>
|
||||||
|
/// <param name="ByteOrder">Word ordering for multi-register types. Ignored for Bool / Int16 / UInt16 / BitInRegister / String.</param>
|
||||||
|
/// <param name="BitIndex">For <c>DataType = BitInRegister</c>: which bit of the holding register (0-15, LSB-first).</param>
|
||||||
|
/// <param name="StringLength">For <c>DataType = String</c>: number of ASCII characters (2 per register, rounded up).</param>
|
||||||
|
public sealed record ModbusTagDefinition(
|
||||||
|
string Name,
|
||||||
|
ModbusRegion Region,
|
||||||
|
ushort Address,
|
||||||
|
ModbusDataType DataType,
|
||||||
|
bool Writable = true,
|
||||||
|
ModbusByteOrder ByteOrder = ModbusByteOrder.BigEndian,
|
||||||
|
byte BitIndex = 0,
|
||||||
|
ushort StringLength = 0);
|
||||||
|
|
||||||
|
public enum ModbusRegion { Coils, DiscreteInputs, InputRegisters, HoldingRegisters }
|
||||||
|
|
||||||
|
public enum ModbusDataType
|
||||||
|
{
|
||||||
|
Bool,
|
||||||
|
Int16,
|
||||||
|
UInt16,
|
||||||
|
Int32,
|
||||||
|
UInt32,
|
||||||
|
Int64,
|
||||||
|
UInt64,
|
||||||
|
Float32,
|
||||||
|
Float64,
|
||||||
|
/// <summary>Single bit within a holding register. <see cref="ModbusTagDefinition.BitIndex"/> selects 0-15 LSB-first.</summary>
|
||||||
|
BitInRegister,
|
||||||
|
/// <summary>ASCII string packed 2 chars per register, <see cref="ModbusTagDefinition.StringLength"/> characters long.</summary>
|
||||||
|
String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Word ordering for multi-register types. Modbus TCP standard is <see cref="BigEndian"/>
|
||||||
|
/// (ABCD for 32-bit: high word at the lower address). Many PLCs — Siemens S7, several
|
||||||
|
/// Allen-Bradley series, some Modicon families — use <see cref="WordSwap"/> (CDAB), which
|
||||||
|
/// keeps bytes big-endian within each register but reverses the word pair(s).
|
||||||
|
/// </summary>
|
||||||
|
public enum ModbusByteOrder
|
||||||
|
{
|
||||||
|
BigEndian,
|
||||||
|
WordSwap,
|
||||||
|
}
|
||||||
113
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs
Normal file
113
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Concrete Modbus TCP transport. Wraps a single <see cref="TcpClient"/> and serializes
|
||||||
|
/// requests so at most one transaction is in-flight at a time — Modbus servers typically
|
||||||
|
/// support concurrent transactions, but the single-flight model keeps the wire trace
|
||||||
|
/// easy to diagnose and avoids interleaved-response correlation bugs.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ModbusTcpTransport : IModbusTransport
|
||||||
|
{
|
||||||
|
private readonly string _host;
|
||||||
|
private readonly int _port;
|
||||||
|
private readonly TimeSpan _timeout;
|
||||||
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
private TcpClient? _client;
|
||||||
|
private NetworkStream? _stream;
|
||||||
|
private ushort _nextTx;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public ModbusTcpTransport(string host, int port, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
_host = host;
|
||||||
|
_port = port;
|
||||||
|
_timeout = timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ConnectAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
_client = new TcpClient();
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
cts.CancelAfter(_timeout);
|
||||||
|
await _client.ConnectAsync(_host, _port, cts.Token).ConfigureAwait(false);
|
||||||
|
_stream = _client.GetStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
|
||||||
|
if (_stream is null) throw new InvalidOperationException("Transport not connected");
|
||||||
|
|
||||||
|
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var txId = ++_nextTx;
|
||||||
|
|
||||||
|
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
|
||||||
|
var adu = new byte[7 + pdu.Length];
|
||||||
|
adu[0] = (byte)(txId >> 8);
|
||||||
|
adu[1] = (byte)(txId & 0xFF);
|
||||||
|
// protocol id already zero
|
||||||
|
var len = (ushort)(1 + pdu.Length); // unit id + pdu
|
||||||
|
adu[4] = (byte)(len >> 8);
|
||||||
|
adu[5] = (byte)(len & 0xFF);
|
||||||
|
adu[6] = unitId;
|
||||||
|
Buffer.BlockCopy(pdu, 0, adu, 7, pdu.Length);
|
||||||
|
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
cts.CancelAfter(_timeout);
|
||||||
|
await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||||
|
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var header = new byte[7];
|
||||||
|
await ReadExactlyAsync(_stream, header, cts.Token).ConfigureAwait(false);
|
||||||
|
var respTxId = (ushort)((header[0] << 8) | header[1]);
|
||||||
|
if (respTxId != txId)
|
||||||
|
throw new InvalidDataException($"Modbus TxId mismatch: expected {txId} got {respTxId}");
|
||||||
|
var respLen = (ushort)((header[4] << 8) | header[5]);
|
||||||
|
if (respLen < 1) throw new InvalidDataException($"Modbus response length too small: {respLen}");
|
||||||
|
var respPdu = new byte[respLen - 1];
|
||||||
|
await ReadExactlyAsync(_stream, respPdu, cts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Exception PDU: function code has high bit set.
|
||||||
|
if ((respPdu[0] & 0x80) != 0)
|
||||||
|
{
|
||||||
|
var fc = (byte)(respPdu[0] & 0x7F);
|
||||||
|
var ex = respPdu[1];
|
||||||
|
throw new ModbusException(fc, ex, $"Modbus exception fc={fc} code={ex}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return respPdu;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var read = 0;
|
||||||
|
while (read < buf.Length)
|
||||||
|
{
|
||||||
|
var n = await s.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
|
||||||
|
if (n == 0) throw new EndOfStreamException("Modbus socket closed mid-response");
|
||||||
|
read += n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
_client?.Dispose();
|
||||||
|
_gate.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Reflection entry point invoked by <c>HistorianPluginLoader</c> in the Host. Kept
|
|
||||||
/// deliberately simple so the plugin contract is a single static factory method.
|
|
||||||
/// </summary>
|
|
||||||
public static class AvevaHistorianPluginEntry
|
|
||||||
{
|
|
||||||
public static IHistorianDataSource Create(HistorianConfiguration config)
|
|
||||||
=> new HistorianDataSource(config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Thread-safe, pure-logic endpoint picker for the Wonderware Historian cluster. Tracks which
|
|
||||||
/// configured nodes are healthy, places failed nodes in a time-bounded cooldown, and hands
|
|
||||||
/// out an ordered list of eligible candidates for the data source to try in sequence.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Design notes:
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>No SDK dependency — fully unit-testable with an injected clock.</item>
|
|
||||||
/// <item>Per-node state is guarded by a single lock; operations are microsecond-scale
|
|
||||||
/// so contention is a non-issue.</item>
|
|
||||||
/// <item>Cooldown is purely passive: a node re-enters the healthy pool the next time
|
|
||||||
/// it is queried after its cooldown window elapses. There is no background probe.</item>
|
|
||||||
/// <item>Nodes are returned in configuration order so operators can express a
|
|
||||||
/// preference (primary first, fallback second).</item>
|
|
||||||
/// <item>When <see cref="HistorianConfiguration.ServerNames"/> is empty, the picker is
|
|
||||||
/// initialized with a single entry from <see cref="HistorianConfiguration.ServerName"/>
|
|
||||||
/// so legacy deployments continue to work unchanged.</item>
|
|
||||||
/// </list>
|
|
||||||
/// </remarks>
|
|
||||||
internal sealed class HistorianClusterEndpointPicker
|
|
||||||
{
|
|
||||||
private readonly Func<DateTime> _clock;
|
|
||||||
private readonly TimeSpan _cooldown;
|
|
||||||
private readonly object _lock = new object();
|
|
||||||
private readonly List<NodeEntry> _nodes;
|
|
||||||
|
|
||||||
public HistorianClusterEndpointPicker(HistorianConfiguration config)
|
|
||||||
: this(config, () => DateTime.UtcNow) { }
|
|
||||||
|
|
||||||
internal HistorianClusterEndpointPicker(HistorianConfiguration config, Func<DateTime> clock)
|
|
||||||
{
|
|
||||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
|
||||||
_cooldown = TimeSpan.FromSeconds(Math.Max(0, config.FailureCooldownSeconds));
|
|
||||||
|
|
||||||
var names = (config.ServerNames != null && config.ServerNames.Count > 0)
|
|
||||||
? config.ServerNames
|
|
||||||
: new List<string> { config.ServerName };
|
|
||||||
|
|
||||||
_nodes = names
|
|
||||||
.Where(n => !string.IsNullOrWhiteSpace(n))
|
|
||||||
.Select(n => n.Trim())
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.Select(n => new NodeEntry { Name = n })
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the total number of configured cluster nodes. Stable — nodes are never added
|
|
||||||
/// or removed after construction.
|
|
||||||
/// </summary>
|
|
||||||
public int NodeCount
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
return _nodes.Count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns an ordered snapshot of nodes currently eligible for a connection attempt,
|
|
||||||
/// with any node whose cooldown has elapsed automatically restored to the pool.
|
|
||||||
/// An empty list means all nodes are in active cooldown.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<string> GetHealthyNodes()
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
var now = _clock();
|
|
||||||
return _nodes
|
|
||||||
.Where(n => IsHealthyAt(n, now))
|
|
||||||
.Select(n => n.Name)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the count of nodes currently eligible for a connection attempt (i.e., not in cooldown).
|
|
||||||
/// </summary>
|
|
||||||
public int HealthyNodeCount
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
var now = _clock();
|
|
||||||
return _nodes.Count(n => IsHealthyAt(n, now));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Places <paramref name="node"/> into cooldown starting at the current clock time.
|
|
||||||
/// Increments the node's failure counter and stores the latest error message for
|
|
||||||
/// surfacing on the dashboard. Unknown node names are ignored.
|
|
||||||
/// </summary>
|
|
||||||
public void MarkFailed(string node, string? error)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
var entry = FindEntry(node);
|
|
||||||
if (entry == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var now = _clock();
|
|
||||||
entry.FailureCount++;
|
|
||||||
entry.LastError = error;
|
|
||||||
entry.LastFailureTime = now;
|
|
||||||
entry.CooldownUntil = _cooldown.TotalMilliseconds > 0 ? now + _cooldown : (DateTime?)null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Marks <paramref name="node"/> as healthy immediately — clears any active cooldown but
|
|
||||||
/// leaves the cumulative failure counter intact for operator diagnostics. Unknown node
|
|
||||||
/// names are ignored.
|
|
||||||
/// </summary>
|
|
||||||
public void MarkHealthy(string node)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
var entry = FindEntry(node);
|
|
||||||
if (entry == null)
|
|
||||||
return;
|
|
||||||
entry.CooldownUntil = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Captures the current per-node state for the health dashboard. Freshly computed from
|
|
||||||
/// <see cref="_clock"/> so recently-expired cooldowns are reported as healthy.
|
|
||||||
/// </summary>
|
|
||||||
public List<HistorianClusterNodeState> SnapshotNodeStates()
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
var now = _clock();
|
|
||||||
return _nodes.Select(n => new HistorianClusterNodeState
|
|
||||||
{
|
|
||||||
Name = n.Name,
|
|
||||||
IsHealthy = IsHealthyAt(n, now),
|
|
||||||
CooldownUntil = IsHealthyAt(n, now) ? null : n.CooldownUntil,
|
|
||||||
FailureCount = n.FailureCount,
|
|
||||||
LastError = n.LastError,
|
|
||||||
LastFailureTime = n.LastFailureTime
|
|
||||||
}).ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsHealthyAt(NodeEntry entry, DateTime now)
|
|
||||||
{
|
|
||||||
return entry.CooldownUntil == null || entry.CooldownUntil <= now;
|
|
||||||
}
|
|
||||||
|
|
||||||
private NodeEntry? FindEntry(string node)
|
|
||||||
{
|
|
||||||
for (var i = 0; i < _nodes.Count; i++)
|
|
||||||
if (string.Equals(_nodes[i].Name, node, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return _nodes[i];
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class NodeEntry
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = "";
|
|
||||||
public DateTime? CooldownUntil { get; set; }
|
|
||||||
public int FailureCount { get; set; }
|
|
||||||
public string? LastError { get; set; }
|
|
||||||
public DateTime? LastFailureTime { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,704 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using StringCollection = System.Collections.Specialized.StringCollection;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using ArchestrA;
|
|
||||||
using Opc.Ua;
|
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Reads historical data from the Wonderware Historian via the aahClientManaged SDK.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class HistorianDataSource : IHistorianDataSource
|
|
||||||
{
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<HistorianDataSource>();
|
|
||||||
|
|
||||||
private readonly HistorianConfiguration _config;
|
|
||||||
private readonly object _connectionLock = new object();
|
|
||||||
private readonly object _eventConnectionLock = new object();
|
|
||||||
private readonly IHistorianConnectionFactory _factory;
|
|
||||||
private HistorianAccess? _connection;
|
|
||||||
private HistorianAccess? _eventConnection;
|
|
||||||
private bool _disposed;
|
|
||||||
|
|
||||||
// Runtime query health state. Guarded by _healthLock — updated on every read
|
|
||||||
// method exit (success or failure) so the dashboard can distinguish "plugin
|
|
||||||
// loaded but never queried" from "plugin loaded and queries are failing".
|
|
||||||
private readonly object _healthLock = new object();
|
|
||||||
private long _totalSuccesses;
|
|
||||||
private long _totalFailures;
|
|
||||||
private int _consecutiveFailures;
|
|
||||||
private DateTime? _lastSuccessTime;
|
|
||||||
private DateTime? _lastFailureTime;
|
|
||||||
private string? _lastError;
|
|
||||||
private string? _activeProcessNode;
|
|
||||||
private string? _activeEventNode;
|
|
||||||
|
|
||||||
// Cluster endpoint picker — shared across process + event paths so a node that
|
|
||||||
// fails on one silo is skipped on the other. Initialized from config at construction.
|
|
||||||
private readonly HistorianClusterEndpointPicker _picker;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a Historian reader that translates OPC UA history requests into Wonderware Historian SDK queries.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="config">The Historian SDK connection settings used for runtime history lookups.</param>
|
|
||||||
public HistorianDataSource(HistorianConfiguration config)
|
|
||||||
: this(config, new SdkHistorianConnectionFactory(), null) { }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a Historian reader with a custom connection factory for testing. When
|
|
||||||
/// <paramref name="picker"/> is <see langword="null"/> a new picker is built from
|
|
||||||
/// <paramref name="config"/>, preserving backward compatibility with existing tests.
|
|
||||||
/// </summary>
|
|
||||||
internal HistorianDataSource(
|
|
||||||
HistorianConfiguration config,
|
|
||||||
IHistorianConnectionFactory factory,
|
|
||||||
HistorianClusterEndpointPicker? picker = null)
|
|
||||||
{
|
|
||||||
_config = config;
|
|
||||||
_factory = factory;
|
|
||||||
_picker = picker ?? new HistorianClusterEndpointPicker(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Iterates the picker's healthy node list, cloning the configuration per attempt and
|
|
||||||
/// handing it to the factory. Marks each tried node as healthy on success or failed on
|
|
||||||
/// exception. Returns the winning connection + node name; throws when no nodes succeed.
|
|
||||||
/// </summary>
|
|
||||||
private (HistorianAccess Connection, string Node) ConnectToAnyHealthyNode(HistorianConnectionType type)
|
|
||||||
{
|
|
||||||
var candidates = _picker.GetHealthyNodes();
|
|
||||||
if (candidates.Count == 0)
|
|
||||||
{
|
|
||||||
var total = _picker.NodeCount;
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
total == 0
|
|
||||||
? "No historian nodes configured"
|
|
||||||
: $"All {total} historian nodes are in cooldown — no healthy endpoints to connect to");
|
|
||||||
}
|
|
||||||
|
|
||||||
Exception? lastException = null;
|
|
||||||
foreach (var node in candidates)
|
|
||||||
{
|
|
||||||
var attemptConfig = CloneConfigWithServerName(node);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var conn = _factory.CreateAndConnect(attemptConfig, type);
|
|
||||||
_picker.MarkHealthy(node);
|
|
||||||
return (conn, node);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_picker.MarkFailed(node, ex.Message);
|
|
||||||
lastException = ex;
|
|
||||||
Log.Warning(ex,
|
|
||||||
"Historian node {Node} failed during connect attempt; trying next candidate", node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var inner = lastException?.Message ?? "(no detail)";
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"All {candidates.Count} healthy historian candidate(s) failed during connect: {inner}",
|
|
||||||
lastException);
|
|
||||||
}
|
|
||||||
|
|
||||||
private HistorianConfiguration CloneConfigWithServerName(string serverName)
|
|
||||||
{
|
|
||||||
return new HistorianConfiguration
|
|
||||||
{
|
|
||||||
Enabled = _config.Enabled,
|
|
||||||
ServerName = serverName,
|
|
||||||
ServerNames = _config.ServerNames,
|
|
||||||
FailureCooldownSeconds = _config.FailureCooldownSeconds,
|
|
||||||
IntegratedSecurity = _config.IntegratedSecurity,
|
|
||||||
UserName = _config.UserName,
|
|
||||||
Password = _config.Password,
|
|
||||||
Port = _config.Port,
|
|
||||||
CommandTimeoutSeconds = _config.CommandTimeoutSeconds,
|
|
||||||
MaxValuesPerRead = _config.MaxValuesPerRead
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public HistorianHealthSnapshot GetHealthSnapshot()
|
|
||||||
{
|
|
||||||
var nodeStates = _picker.SnapshotNodeStates();
|
|
||||||
var healthyCount = 0;
|
|
||||||
foreach (var n in nodeStates)
|
|
||||||
if (n.IsHealthy)
|
|
||||||
healthyCount++;
|
|
||||||
|
|
||||||
lock (_healthLock)
|
|
||||||
{
|
|
||||||
return new HistorianHealthSnapshot
|
|
||||||
{
|
|
||||||
TotalQueries = _totalSuccesses + _totalFailures,
|
|
||||||
TotalSuccesses = _totalSuccesses,
|
|
||||||
TotalFailures = _totalFailures,
|
|
||||||
ConsecutiveFailures = _consecutiveFailures,
|
|
||||||
LastSuccessTime = _lastSuccessTime,
|
|
||||||
LastFailureTime = _lastFailureTime,
|
|
||||||
LastError = _lastError,
|
|
||||||
ProcessConnectionOpen = Volatile.Read(ref _connection) != null,
|
|
||||||
EventConnectionOpen = Volatile.Read(ref _eventConnection) != null,
|
|
||||||
ActiveProcessNode = _activeProcessNode,
|
|
||||||
ActiveEventNode = _activeEventNode,
|
|
||||||
NodeCount = nodeStates.Count,
|
|
||||||
HealthyNodeCount = healthyCount,
|
|
||||||
Nodes = nodeStates
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RecordSuccess()
|
|
||||||
{
|
|
||||||
lock (_healthLock)
|
|
||||||
{
|
|
||||||
_totalSuccesses++;
|
|
||||||
_lastSuccessTime = DateTime.UtcNow;
|
|
||||||
_consecutiveFailures = 0;
|
|
||||||
_lastError = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RecordFailure(string error)
|
|
||||||
{
|
|
||||||
lock (_healthLock)
|
|
||||||
{
|
|
||||||
_totalFailures++;
|
|
||||||
_lastFailureTime = DateTime.UtcNow;
|
|
||||||
_consecutiveFailures++;
|
|
||||||
_lastError = error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EnsureConnected()
|
|
||||||
{
|
|
||||||
if (_disposed)
|
|
||||||
throw new ObjectDisposedException(nameof(HistorianDataSource));
|
|
||||||
|
|
||||||
// Fast path: already connected (no lock needed)
|
|
||||||
if (Volatile.Read(ref _connection) != null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Create and wait for connection outside the lock so concurrent history
|
|
||||||
// requests are not serialized behind a slow Historian handshake. The cluster
|
|
||||||
// picker iterates configured nodes and returns the first that successfully connects.
|
|
||||||
var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Process);
|
|
||||||
|
|
||||||
lock (_connectionLock)
|
|
||||||
{
|
|
||||||
if (_disposed)
|
|
||||||
{
|
|
||||||
conn.CloseConnection(out _);
|
|
||||||
conn.Dispose();
|
|
||||||
throw new ObjectDisposedException(nameof(HistorianDataSource));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_connection != null)
|
|
||||||
{
|
|
||||||
// Another thread connected while we were waiting
|
|
||||||
conn.CloseConnection(out _);
|
|
||||||
conn.Dispose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_connection = conn;
|
|
||||||
lock (_healthLock)
|
|
||||||
_activeProcessNode = winningNode;
|
|
||||||
Log.Information("Historian SDK connection opened to {Server}:{Port}", winningNode, _config.Port);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleConnectionError(Exception? ex = null)
|
|
||||||
{
|
|
||||||
lock (_connectionLock)
|
|
||||||
{
|
|
||||||
if (_connection == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_connection.CloseConnection(out _);
|
|
||||||
_connection.Dispose();
|
|
||||||
}
|
|
||||||
catch (Exception disposeEx)
|
|
||||||
{
|
|
||||||
Log.Debug(disposeEx, "Error disposing Historian SDK connection during error recovery");
|
|
||||||
}
|
|
||||||
|
|
||||||
_connection = null;
|
|
||||||
string? failedNode;
|
|
||||||
lock (_healthLock)
|
|
||||||
{
|
|
||||||
failedNode = _activeProcessNode;
|
|
||||||
_activeProcessNode = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failedNode != null)
|
|
||||||
_picker.MarkFailed(failedNode, ex?.Message ?? "mid-query failure");
|
|
||||||
Log.Warning(ex, "Historian SDK connection reset (node={Node}) — will reconnect on next request",
|
|
||||||
failedNode ?? "(unknown)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EnsureEventConnected()
|
|
||||||
{
|
|
||||||
if (_disposed)
|
|
||||||
throw new ObjectDisposedException(nameof(HistorianDataSource));
|
|
||||||
|
|
||||||
if (Volatile.Read(ref _eventConnection) != null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Event);
|
|
||||||
|
|
||||||
lock (_eventConnectionLock)
|
|
||||||
{
|
|
||||||
if (_disposed)
|
|
||||||
{
|
|
||||||
conn.CloseConnection(out _);
|
|
||||||
conn.Dispose();
|
|
||||||
throw new ObjectDisposedException(nameof(HistorianDataSource));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_eventConnection != null)
|
|
||||||
{
|
|
||||||
conn.CloseConnection(out _);
|
|
||||||
conn.Dispose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_eventConnection = conn;
|
|
||||||
lock (_healthLock)
|
|
||||||
_activeEventNode = winningNode;
|
|
||||||
Log.Information("Historian SDK event connection opened to {Server}:{Port}",
|
|
||||||
winningNode, _config.Port);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleEventConnectionError(Exception? ex = null)
|
|
||||||
{
|
|
||||||
lock (_eventConnectionLock)
|
|
||||||
{
|
|
||||||
if (_eventConnection == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_eventConnection.CloseConnection(out _);
|
|
||||||
_eventConnection.Dispose();
|
|
||||||
}
|
|
||||||
catch (Exception disposeEx)
|
|
||||||
{
|
|
||||||
Log.Debug(disposeEx, "Error disposing Historian SDK event connection during error recovery");
|
|
||||||
}
|
|
||||||
|
|
||||||
_eventConnection = null;
|
|
||||||
string? failedNode;
|
|
||||||
lock (_healthLock)
|
|
||||||
{
|
|
||||||
failedNode = _activeEventNode;
|
|
||||||
_activeEventNode = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failedNode != null)
|
|
||||||
_picker.MarkFailed(failedNode, ex?.Message ?? "mid-query failure");
|
|
||||||
Log.Warning(ex, "Historian SDK event connection reset (node={Node}) — will reconnect on next request",
|
|
||||||
failedNode ?? "(unknown)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<List<DataValue>> ReadRawAsync(
|
|
||||||
string tagName, DateTime startTime, DateTime endTime, int maxValues,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var results = new List<DataValue>();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
EnsureConnected();
|
|
||||||
|
|
||||||
using var query = _connection!.CreateHistoryQuery();
|
|
||||||
var args = new HistoryQueryArgs
|
|
||||||
{
|
|
||||||
TagNames = new StringCollection { tagName },
|
|
||||||
StartDateTime = startTime,
|
|
||||||
EndDateTime = endTime,
|
|
||||||
RetrievalMode = HistorianRetrievalMode.Full
|
|
||||||
};
|
|
||||||
|
|
||||||
if (maxValues > 0)
|
|
||||||
args.BatchSize = (uint)maxValues;
|
|
||||||
else if (_config.MaxValuesPerRead > 0)
|
|
||||||
args.BatchSize = (uint)_config.MaxValuesPerRead;
|
|
||||||
|
|
||||||
if (!query.StartQuery(args, out var error))
|
|
||||||
{
|
|
||||||
Log.Warning("Historian SDK raw query start failed for {Tag}: {Error}", tagName, error.ErrorCode);
|
|
||||||
RecordFailure($"raw StartQuery: {error.ErrorCode}");
|
|
||||||
HandleConnectionError();
|
|
||||||
return Task.FromResult(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
var count = 0;
|
|
||||||
var limit = maxValues > 0 ? maxValues : _config.MaxValuesPerRead;
|
|
||||||
|
|
||||||
while (query.MoveNext(out error))
|
|
||||||
{
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
var result = query.QueryResult;
|
|
||||||
var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc);
|
|
||||||
|
|
||||||
object? value;
|
|
||||||
if (!string.IsNullOrEmpty(result.StringValue) && result.Value == 0)
|
|
||||||
value = result.StringValue;
|
|
||||||
else
|
|
||||||
value = result.Value;
|
|
||||||
|
|
||||||
var quality = (byte)(result.OpcQuality & 0xFF);
|
|
||||||
|
|
||||||
results.Add(new DataValue
|
|
||||||
{
|
|
||||||
Value = new Variant(value),
|
|
||||||
SourceTimestamp = timestamp,
|
|
||||||
ServerTimestamp = timestamp,
|
|
||||||
StatusCode = QualityMapper.MapToOpcUaStatusCode(QualityMapper.MapFromMxAccessQuality(quality))
|
|
||||||
});
|
|
||||||
|
|
||||||
count++;
|
|
||||||
if (limit > 0 && count >= limit)
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
query.EndQuery(out _);
|
|
||||||
RecordSuccess();
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (ObjectDisposedException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "HistoryRead raw failed for {Tag}", tagName);
|
|
||||||
RecordFailure($"raw: {ex.Message}");
|
|
||||||
HandleConnectionError(ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Debug("HistoryRead raw: {Tag} returned {Count} values ({Start} to {End})",
|
|
||||||
tagName, results.Count, startTime, endTime);
|
|
||||||
|
|
||||||
return Task.FromResult(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<List<DataValue>> ReadAggregateAsync(
|
|
||||||
string tagName, DateTime startTime, DateTime endTime,
|
|
||||||
double intervalMs, string aggregateColumn,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var results = new List<DataValue>();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
EnsureConnected();
|
|
||||||
|
|
||||||
using var query = _connection!.CreateAnalogSummaryQuery();
|
|
||||||
var args = new AnalogSummaryQueryArgs
|
|
||||||
{
|
|
||||||
TagNames = new StringCollection { tagName },
|
|
||||||
StartDateTime = startTime,
|
|
||||||
EndDateTime = endTime,
|
|
||||||
Resolution = (ulong)intervalMs
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!query.StartQuery(args, out var error))
|
|
||||||
{
|
|
||||||
Log.Warning("Historian SDK aggregate query start failed for {Tag}: {Error}", tagName,
|
|
||||||
error.ErrorCode);
|
|
||||||
RecordFailure($"aggregate StartQuery: {error.ErrorCode}");
|
|
||||||
HandleConnectionError();
|
|
||||||
return Task.FromResult(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
while (query.MoveNext(out error))
|
|
||||||
{
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
var result = query.QueryResult;
|
|
||||||
var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc);
|
|
||||||
var value = ExtractAggregateValue(result, aggregateColumn);
|
|
||||||
|
|
||||||
results.Add(new DataValue
|
|
||||||
{
|
|
||||||
Value = new Variant(value),
|
|
||||||
SourceTimestamp = timestamp,
|
|
||||||
ServerTimestamp = timestamp,
|
|
||||||
StatusCode = value != null ? StatusCodes.Good : StatusCodes.BadNoData
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
query.EndQuery(out _);
|
|
||||||
RecordSuccess();
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (ObjectDisposedException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "HistoryRead aggregate failed for {Tag}", tagName);
|
|
||||||
RecordFailure($"aggregate: {ex.Message}");
|
|
||||||
HandleConnectionError(ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Debug("HistoryRead aggregate ({Aggregate}): {Tag} returned {Count} values",
|
|
||||||
aggregateColumn, tagName, results.Count);
|
|
||||||
|
|
||||||
return Task.FromResult(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<List<DataValue>> ReadAtTimeAsync(
|
|
||||||
string tagName, DateTime[] timestamps,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var results = new List<DataValue>();
|
|
||||||
|
|
||||||
if (timestamps == null || timestamps.Length == 0)
|
|
||||||
return Task.FromResult(results);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
EnsureConnected();
|
|
||||||
|
|
||||||
foreach (var timestamp in timestamps)
|
|
||||||
{
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
using var query = _connection!.CreateHistoryQuery();
|
|
||||||
var args = new HistoryQueryArgs
|
|
||||||
{
|
|
||||||
TagNames = new StringCollection { tagName },
|
|
||||||
StartDateTime = timestamp,
|
|
||||||
EndDateTime = timestamp,
|
|
||||||
RetrievalMode = HistorianRetrievalMode.Interpolated,
|
|
||||||
BatchSize = 1
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!query.StartQuery(args, out var error))
|
|
||||||
{
|
|
||||||
results.Add(new DataValue
|
|
||||||
{
|
|
||||||
Value = Variant.Null,
|
|
||||||
SourceTimestamp = timestamp,
|
|
||||||
ServerTimestamp = timestamp,
|
|
||||||
StatusCode = StatusCodes.BadNoData
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.MoveNext(out error))
|
|
||||||
{
|
|
||||||
var result = query.QueryResult;
|
|
||||||
object? value;
|
|
||||||
if (!string.IsNullOrEmpty(result.StringValue) && result.Value == 0)
|
|
||||||
value = result.StringValue;
|
|
||||||
else
|
|
||||||
value = result.Value;
|
|
||||||
|
|
||||||
var quality = (byte)(result.OpcQuality & 0xFF);
|
|
||||||
results.Add(new DataValue
|
|
||||||
{
|
|
||||||
Value = new Variant(value),
|
|
||||||
SourceTimestamp = timestamp,
|
|
||||||
ServerTimestamp = timestamp,
|
|
||||||
StatusCode = QualityMapper.MapToOpcUaStatusCode(
|
|
||||||
QualityMapper.MapFromMxAccessQuality(quality))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
results.Add(new DataValue
|
|
||||||
{
|
|
||||||
Value = Variant.Null,
|
|
||||||
SourceTimestamp = timestamp,
|
|
||||||
ServerTimestamp = timestamp,
|
|
||||||
StatusCode = StatusCodes.BadNoData
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
query.EndQuery(out _);
|
|
||||||
}
|
|
||||||
RecordSuccess();
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (ObjectDisposedException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "HistoryRead at-time failed for {Tag}", tagName);
|
|
||||||
RecordFailure($"at-time: {ex.Message}");
|
|
||||||
HandleConnectionError(ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Debug("HistoryRead at-time: {Tag} returned {Count} values for {Timestamps} timestamps",
|
|
||||||
tagName, results.Count, timestamps.Length);
|
|
||||||
|
|
||||||
return Task.FromResult(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<List<HistorianEventDto>> ReadEventsAsync(
|
|
||||||
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var results = new List<HistorianEventDto>();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
EnsureEventConnected();
|
|
||||||
|
|
||||||
using var query = _eventConnection!.CreateEventQuery();
|
|
||||||
var args = new EventQueryArgs
|
|
||||||
{
|
|
||||||
StartDateTime = startTime,
|
|
||||||
EndDateTime = endTime,
|
|
||||||
EventCount = maxEvents > 0 ? (uint)maxEvents : (uint)_config.MaxValuesPerRead,
|
|
||||||
QueryType = HistorianEventQueryType.Events,
|
|
||||||
EventOrder = HistorianEventOrder.Ascending
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(sourceName))
|
|
||||||
{
|
|
||||||
query.AddEventFilter("Source", HistorianComparisionType.Equal, sourceName, out _);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!query.StartQuery(args, out var error))
|
|
||||||
{
|
|
||||||
Log.Warning("Historian SDK event query start failed: {Error}", error.ErrorCode);
|
|
||||||
RecordFailure($"events StartQuery: {error.ErrorCode}");
|
|
||||||
HandleEventConnectionError();
|
|
||||||
return Task.FromResult(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
var count = 0;
|
|
||||||
while (query.MoveNext(out error))
|
|
||||||
{
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
results.Add(ToDto(query.QueryResult));
|
|
||||||
count++;
|
|
||||||
if (maxEvents > 0 && count >= maxEvents)
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
query.EndQuery(out _);
|
|
||||||
RecordSuccess();
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (ObjectDisposedException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "HistoryRead events failed for source {Source}", sourceName ?? "(all)");
|
|
||||||
RecordFailure($"events: {ex.Message}");
|
|
||||||
HandleEventConnectionError(ex);
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Debug("HistoryRead events: source={Source} returned {Count} events ({Start} to {End})",
|
|
||||||
sourceName ?? "(all)", results.Count, startTime, endTime);
|
|
||||||
|
|
||||||
return Task.FromResult(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HistorianEventDto ToDto(HistorianEvent evt)
|
|
||||||
{
|
|
||||||
return new HistorianEventDto
|
|
||||||
{
|
|
||||||
Id = evt.Id,
|
|
||||||
Source = evt.Source,
|
|
||||||
EventTime = evt.EventTime,
|
|
||||||
ReceivedTime = evt.ReceivedTime,
|
|
||||||
DisplayText = evt.DisplayText,
|
|
||||||
Severity = (ushort)evt.Severity
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extracts the requested aggregate value from an <see cref="AnalogSummaryQueryResult"/> by column name.
|
|
||||||
/// </summary>
|
|
||||||
internal static double? ExtractAggregateValue(AnalogSummaryQueryResult result, string column)
|
|
||||||
{
|
|
||||||
switch (column)
|
|
||||||
{
|
|
||||||
case "Average": return result.Average;
|
|
||||||
case "Minimum": return result.Minimum;
|
|
||||||
case "Maximum": return result.Maximum;
|
|
||||||
case "ValueCount": return result.ValueCount;
|
|
||||||
case "First": return result.First;
|
|
||||||
case "Last": return result.Last;
|
|
||||||
case "StdDev": return result.StdDev;
|
|
||||||
default: return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Closes the Historian SDK connection and releases resources.
|
|
||||||
/// </summary>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (_disposed)
|
|
||||||
return;
|
|
||||||
_disposed = true;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_connection?.CloseConnection(out _);
|
|
||||||
_connection?.Dispose();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Error closing Historian SDK connection");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_eventConnection?.CloseConnection(out _);
|
|
||||||
_eventConnection?.Dispose();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Error closing Historian SDK event connection");
|
|
||||||
}
|
|
||||||
|
|
||||||
_connection = null;
|
|
||||||
_eventConnection = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using ArchestrA;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Creates and opens Historian SDK connections. Extracted so tests can inject
|
|
||||||
/// fakes that control connection success, failure, and timeout behavior.
|
|
||||||
/// </summary>
|
|
||||||
internal interface IHistorianConnectionFactory
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new Historian SDK connection, opens it, and waits until it is ready.
|
|
||||||
/// Throws on connection failure or timeout.
|
|
||||||
/// </summary>
|
|
||||||
HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Production implementation that creates real Historian SDK connections.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class SdkHistorianConnectionFactory : IHistorianConnectionFactory
|
|
||||||
{
|
|
||||||
public HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type)
|
|
||||||
{
|
|
||||||
var conn = new HistorianAccess();
|
|
||||||
|
|
||||||
var args = new HistorianConnectionArgs
|
|
||||||
{
|
|
||||||
ServerName = config.ServerName,
|
|
||||||
TcpPort = (ushort)config.Port,
|
|
||||||
IntegratedSecurity = config.IntegratedSecurity,
|
|
||||||
UseArchestrAUser = config.IntegratedSecurity,
|
|
||||||
ConnectionType = type,
|
|
||||||
ReadOnly = true,
|
|
||||||
PacketTimeout = (uint)(config.CommandTimeoutSeconds * 1000)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!config.IntegratedSecurity)
|
|
||||||
{
|
|
||||||
args.UserName = config.UserName ?? string.Empty;
|
|
||||||
args.Password = config.Password ?? string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!conn.OpenConnection(args, out var error))
|
|
||||||
{
|
|
||||||
conn.Dispose();
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Failed to open Historian SDK connection to {config.ServerName}:{config.Port}: {error.ErrorCode}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The SDK connects asynchronously — poll until the connection is ready
|
|
||||||
var timeoutMs = config.CommandTimeoutSeconds * 1000;
|
|
||||||
var elapsed = 0;
|
|
||||||
while (elapsed < timeoutMs)
|
|
||||||
{
|
|
||||||
var status = new HistorianConnectionStatus();
|
|
||||||
conn.GetConnectionStatus(ref status);
|
|
||||||
|
|
||||||
if (status.ConnectedToServer)
|
|
||||||
return conn;
|
|
||||||
|
|
||||||
if (status.ErrorOccurred)
|
|
||||||
{
|
|
||||||
conn.Dispose();
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Historian SDK connection failed: {status.Error}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Thread.Sleep(250);
|
|
||||||
elapsed += 250;
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.Dispose();
|
|
||||||
throw new TimeoutException(
|
|
||||||
$"Historian SDK connection to {config.ServerName}:{config.Port} timed out after {config.CommandTimeoutSeconds}s");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net48</TargetFramework>
|
|
||||||
<PlatformTarget>x86</PlatformTarget>
|
|
||||||
<LangVersion>9.0</LangVersion>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Historian.Aveva</RootNamespace>
|
|
||||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Historian.Aveva</AssemblyName>
|
|
||||||
<!-- Plugin is loaded at runtime via Assembly.LoadFrom; never copy it as a CopyLocal dep. -->
|
|
||||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
|
||||||
<!-- Deploy next to Host.exe under bin/<cfg>/Historian/ so F5 works without a manual copy. -->
|
|
||||||
<HistorianPluginOutputDir>$(MSBuildThisFileDirectory)..\ZB.MOM.WW.OtOpcUa.Host\bin\$(Configuration)\net48\Historian\</HistorianPluginOutputDir>
|
|
||||||
<!--
|
|
||||||
Phase 2 Stream D — V1 ARCHIVE. Plugs into the legacy in-process Host's
|
|
||||||
Wonderware Historian loader. Will be ported into Driver.Galaxy.Host's
|
|
||||||
Backend/Historian/ subtree when MxAccessGalaxyBackend.HistoryReadAsync is
|
|
||||||
wired (Task B.1.h follow-up). See docs/v2/V1_ARCHIVE_STATUS.md.
|
|
||||||
-->
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests"/>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Logging -->
|
|
||||||
<PackageReference Include="Serilog" Version="2.10.0"/>
|
|
||||||
|
|
||||||
<!-- OPC UA (for DataValue/StatusCodes used by the IHistorianDataSource surface) -->
|
|
||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126"/>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Private=false: the plugin binds to Host types at compile time but Host.exe must not be
|
|
||||||
copied into the plugin's output folder (it is already in the process). -->
|
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj">
|
|
||||||
<Private>false</Private>
|
|
||||||
<ReferenceOutputAssembly>true</ReferenceOutputAssembly>
|
|
||||||
</ProjectReference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Wonderware Historian SDK -->
|
|
||||||
<Reference Include="aahClientManaged">
|
|
||||||
<HintPath>..\..\lib\aahClientManaged.dll</HintPath>
|
|
||||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="aahClientCommon">
|
|
||||||
<HintPath>..\..\lib\aahClientCommon.dll</HintPath>
|
|
||||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
|
||||||
</Reference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Historian SDK native dependencies — copied beside the plugin DLL so the AssemblyResolve
|
|
||||||
handler in HistorianPluginLoader can find them when the plugin first JITs. -->
|
|
||||||
<None Include="..\..\lib\aahClient.dll">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Include="..\..\lib\aahClientCommon.dll">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Include="..\..\lib\aahClientManaged.dll">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Include="..\..\lib\Historian.CBE.dll">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Include="..\..\lib\Historian.DPAPI.dll">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Include="..\..\lib\ArchestrA.CloudHistorian.Contract.dll">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<Target Name="StageHistorianPluginForHost" AfterTargets="Build">
|
|
||||||
<ItemGroup>
|
|
||||||
<_HistorianStageFiles Include="$(OutDir)aahClient.dll"/>
|
|
||||||
<_HistorianStageFiles Include="$(OutDir)aahClientCommon.dll"/>
|
|
||||||
<_HistorianStageFiles Include="$(OutDir)aahClientManaged.dll"/>
|
|
||||||
<_HistorianStageFiles Include="$(OutDir)Historian.CBE.dll"/>
|
|
||||||
<_HistorianStageFiles Include="$(OutDir)Historian.DPAPI.dll"/>
|
|
||||||
<_HistorianStageFiles Include="$(OutDir)ArchestrA.CloudHistorian.Contract.dll"/>
|
|
||||||
<_HistorianStageFiles Include="$(OutDir)$(AssemblyName).dll"/>
|
|
||||||
<_HistorianStageFiles Include="$(OutDir)$(AssemblyName).pdb" Condition="Exists('$(OutDir)$(AssemblyName).pdb')"/>
|
|
||||||
</ItemGroup>
|
|
||||||
<MakeDir Directories="$(HistorianPluginOutputDir)"/>
|
|
||||||
<Copy SourceFiles="@(_HistorianStageFiles)" DestinationFolder="$(HistorianPluginOutputDir)" SkipUnchangedFiles="true"/>
|
|
||||||
</Target>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Configures the template-based alarm object filter under <c>OpcUa.AlarmFilter</c>.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Each entry in <see cref="ObjectFilters"/> is a wildcard pattern matched against the template
|
|
||||||
/// derivation chain of every Galaxy object. Supported wildcard: <c>*</c>. Matching is case-insensitive
|
|
||||||
/// and the leading <c>$</c> used by Galaxy template tag_names is normalized away, so operators can
|
|
||||||
/// write <c>TestMachine*</c> instead of <c>$TestMachine*</c>. An entry may itself contain comma-separated
|
|
||||||
/// patterns for convenience (e.g., <c>"TestMachine*, Pump_*"</c>). An empty list disables the filter,
|
|
||||||
/// restoring current behavior: all alarm-bearing objects are monitored when
|
|
||||||
/// <see cref="OpcUaConfiguration.AlarmTrackingEnabled"/> is <see langword="true"/>.
|
|
||||||
/// </remarks>
|
|
||||||
public class AlarmFilterConfiguration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the wildcard patterns that select which Galaxy objects contribute alarm conditions.
|
|
||||||
/// An object is included when any template in its derivation chain matches any pattern, and the
|
|
||||||
/// inclusion propagates to all descendants in the containment hierarchy. Each object is evaluated
|
|
||||||
/// once: overlapping matches never create duplicate alarm subscriptions.
|
|
||||||
/// </summary>
|
|
||||||
public List<string> ObjectFilters { get; set; } = new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Top-level configuration holder binding all sections from appsettings.json. (SVC-003)
|
|
||||||
/// </summary>
|
|
||||||
public class AppConfiguration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the OPC UA endpoint settings exposed to downstream clients that browse the LMX address space.
|
|
||||||
/// </summary>
|
|
||||||
public OpcUaConfiguration OpcUa { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the MXAccess runtime connection settings used to read and write live Galaxy attributes.
|
|
||||||
/// </summary>
|
|
||||||
public MxAccessConfiguration MxAccess { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the repository settings used to query Galaxy metadata for address-space construction.
|
|
||||||
/// </summary>
|
|
||||||
public GalaxyRepositoryConfiguration GalaxyRepository { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the embedded dashboard settings used to surface service health to operators.
|
|
||||||
/// </summary>
|
|
||||||
public DashboardConfiguration Dashboard { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Wonderware Historian connection settings used to serve OPC UA historical data.
|
|
||||||
/// </summary>
|
|
||||||
public HistorianConfiguration Historian { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the authentication and role-based access control settings.
|
|
||||||
/// </summary>
|
|
||||||
public AuthenticationConfiguration Authentication { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the transport security settings that control which OPC UA security profiles are exposed.
|
|
||||||
/// </summary>
|
|
||||||
public SecurityProfileConfiguration Security { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the redundancy settings that control how this server participates in a redundant pair.
|
|
||||||
/// </summary>
|
|
||||||
public RedundancyConfiguration Redundancy { get; set; } = new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Authentication and role-based access control settings for the OPC UA server.
|
|
||||||
/// </summary>
|
|
||||||
public class AuthenticationConfiguration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether anonymous OPC UA connections are accepted.
|
|
||||||
/// </summary>
|
|
||||||
public bool AllowAnonymous { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether anonymous users can write tag values.
|
|
||||||
/// When false, only authenticated users can write. Existing security classification restrictions still apply.
|
|
||||||
/// </summary>
|
|
||||||
public bool AnonymousCanWrite { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the LDAP authentication settings. When Ldap.Enabled is true,
|
|
||||||
/// credentials are validated against the LDAP server and group membership determines permissions.
|
|
||||||
/// </summary>
|
|
||||||
public LdapConfiguration Ldap { get; set; } = new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Data.SqlClient;
|
|
||||||
using System.Linq;
|
|
||||||
using Opc.Ua;
|
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Validates and logs effective configuration at startup. (SVC-003, SVC-005)
|
|
||||||
/// </summary>
|
|
||||||
public static class ConfigurationValidator
|
|
||||||
{
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator));
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates the effective host configuration and writes the resolved values to the startup log before service
|
|
||||||
/// initialization continues.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="config">
|
|
||||||
/// The bound service configuration that drives OPC UA hosting, MXAccess connectivity, Galaxy queries,
|
|
||||||
/// and dashboard behavior.
|
|
||||||
/// </param>
|
|
||||||
/// <returns>
|
|
||||||
/// <see langword="true" /> when the required settings are present and within supported bounds; otherwise,
|
|
||||||
/// <see langword="false" />.
|
|
||||||
/// </returns>
|
|
||||||
public static bool ValidateAndLog(AppConfiguration config)
|
|
||||||
{
|
|
||||||
var valid = true;
|
|
||||||
|
|
||||||
Log.Information("=== Effective Configuration ===");
|
|
||||||
|
|
||||||
// OPC UA
|
|
||||||
Log.Information(
|
|
||||||
"OpcUa.BindAddress={BindAddress}, Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}",
|
|
||||||
config.OpcUa.BindAddress, config.OpcUa.Port, config.OpcUa.EndpointPath, config.OpcUa.ServerName,
|
|
||||||
config.OpcUa.GalaxyName);
|
|
||||||
Log.Information("OpcUa.MaxSessions={MaxSessions}, SessionTimeoutMinutes={SessionTimeout}",
|
|
||||||
config.OpcUa.MaxSessions, config.OpcUa.SessionTimeoutMinutes);
|
|
||||||
|
|
||||||
if (config.OpcUa.Port < 1 || config.OpcUa.Port > 65535)
|
|
||||||
{
|
|
||||||
Log.Error("OpcUa.Port must be between 1 and 65535");
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(config.OpcUa.GalaxyName))
|
|
||||||
{
|
|
||||||
Log.Error("OpcUa.GalaxyName must not be empty");
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alarm filter
|
|
||||||
var alarmFilterCount = config.OpcUa.AlarmFilter?.ObjectFilters?.Count ?? 0;
|
|
||||||
Log.Information(
|
|
||||||
"OpcUa.AlarmTrackingEnabled={AlarmEnabled}, AlarmFilter.ObjectFilters=[{Filters}]",
|
|
||||||
config.OpcUa.AlarmTrackingEnabled,
|
|
||||||
alarmFilterCount == 0 ? "(none)" : string.Join(", ", config.OpcUa.AlarmFilter!.ObjectFilters));
|
|
||||||
if (alarmFilterCount > 0 && !config.OpcUa.AlarmTrackingEnabled)
|
|
||||||
Log.Warning(
|
|
||||||
"OpcUa.AlarmFilter.ObjectFilters has {Count} patterns but OpcUa.AlarmTrackingEnabled is false — filter will have no effect",
|
|
||||||
alarmFilterCount);
|
|
||||||
|
|
||||||
// MxAccess
|
|
||||||
Log.Information(
|
|
||||||
"MxAccess.ClientName={ClientName}, ReadTimeout={ReadTimeout}s, WriteTimeout={WriteTimeout}s, MaxConcurrent={MaxConcurrent}",
|
|
||||||
config.MxAccess.ClientName, config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds,
|
|
||||||
config.MxAccess.MaxConcurrentOperations);
|
|
||||||
Log.Information(
|
|
||||||
"MxAccess.MonitorInterval={MonitorInterval}s, AutoReconnect={AutoReconnect}, ProbeTag={ProbeTag}, ProbeStaleThreshold={ProbeStale}s",
|
|
||||||
config.MxAccess.MonitorIntervalSeconds, config.MxAccess.AutoReconnect,
|
|
||||||
config.MxAccess.ProbeTag ?? "(none)", config.MxAccess.ProbeStaleThresholdSeconds);
|
|
||||||
Log.Information(
|
|
||||||
"MxAccess.RuntimeStatusProbesEnabled={Enabled}, RuntimeStatusUnknownTimeoutSeconds={Timeout}s, RequestTimeoutSeconds={RequestTimeout}s",
|
|
||||||
config.MxAccess.RuntimeStatusProbesEnabled, config.MxAccess.RuntimeStatusUnknownTimeoutSeconds,
|
|
||||||
config.MxAccess.RequestTimeoutSeconds);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(config.MxAccess.ClientName))
|
|
||||||
{
|
|
||||||
Log.Error("MxAccess.ClientName must not be empty");
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.MxAccess.RuntimeStatusUnknownTimeoutSeconds < 5)
|
|
||||||
Log.Warning(
|
|
||||||
"MxAccess.RuntimeStatusUnknownTimeoutSeconds={Timeout} is below the recommended floor of 5s; initial probe resolution may time out before MxAccess has delivered the first callback",
|
|
||||||
config.MxAccess.RuntimeStatusUnknownTimeoutSeconds);
|
|
||||||
|
|
||||||
if (config.MxAccess.RequestTimeoutSeconds < 1)
|
|
||||||
{
|
|
||||||
Log.Error("MxAccess.RequestTimeoutSeconds must be at least 1");
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
else if (config.MxAccess.RequestTimeoutSeconds <
|
|
||||||
Math.Max(config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds))
|
|
||||||
{
|
|
||||||
Log.Warning(
|
|
||||||
"MxAccess.RequestTimeoutSeconds={RequestTimeout} is below Read/Write inner timeouts ({Read}s/{Write}s); outer safety bound may fire before the inner client completes its own error path",
|
|
||||||
config.MxAccess.RequestTimeoutSeconds,
|
|
||||||
config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Galaxy Repository
|
|
||||||
Log.Information(
|
|
||||||
"GalaxyRepository.ConnectionString={ConnectionString}, ChangeDetectionInterval={ChangeInterval}s, CommandTimeout={CmdTimeout}s, ExtendedAttributes={ExtendedAttributes}",
|
|
||||||
SanitizeConnectionString(config.GalaxyRepository.ConnectionString), config.GalaxyRepository.ChangeDetectionIntervalSeconds,
|
|
||||||
config.GalaxyRepository.CommandTimeoutSeconds, config.GalaxyRepository.ExtendedAttributes);
|
|
||||||
|
|
||||||
var effectivePlatformName = string.IsNullOrWhiteSpace(config.GalaxyRepository.PlatformName)
|
|
||||||
? Environment.MachineName
|
|
||||||
: config.GalaxyRepository.PlatformName;
|
|
||||||
Log.Information(
|
|
||||||
"GalaxyRepository.Scope={Scope}, PlatformName={PlatformName}",
|
|
||||||
config.GalaxyRepository.Scope,
|
|
||||||
config.GalaxyRepository.Scope == GalaxyScope.LocalPlatform
|
|
||||||
? effectivePlatformName
|
|
||||||
: "(n/a)");
|
|
||||||
|
|
||||||
if (config.GalaxyRepository.Scope == GalaxyScope.LocalPlatform &&
|
|
||||||
string.IsNullOrWhiteSpace(config.GalaxyRepository.PlatformName))
|
|
||||||
Log.Information(
|
|
||||||
"GalaxyRepository.PlatformName not set — using Environment.MachineName '{MachineName}'",
|
|
||||||
Environment.MachineName);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(config.GalaxyRepository.ConnectionString))
|
|
||||||
{
|
|
||||||
Log.Error("GalaxyRepository.ConnectionString must not be empty");
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dashboard
|
|
||||||
Log.Information("Dashboard.Enabled={Enabled}, Port={Port}, RefreshInterval={Refresh}s",
|
|
||||||
config.Dashboard.Enabled, config.Dashboard.Port, config.Dashboard.RefreshIntervalSeconds);
|
|
||||||
|
|
||||||
// Security
|
|
||||||
Log.Information(
|
|
||||||
"Security.Profiles=[{Profiles}], AutoAcceptClientCertificates={AutoAccept}, RejectSHA1={RejectSHA1}, MinKeySize={MinKeySize}",
|
|
||||||
string.Join(", ", config.Security.Profiles), config.Security.AutoAcceptClientCertificates,
|
|
||||||
config.Security.RejectSHA1Certificates, config.Security.MinimumCertificateKeySize);
|
|
||||||
|
|
||||||
Log.Information("Security.PkiRootPath={PkiRootPath}", config.Security.PkiRootPath ?? "(default)");
|
|
||||||
Log.Information("Security.CertificateSubject={CertificateSubject}", config.Security.CertificateSubject ?? "(default)");
|
|
||||||
Log.Information("Security.CertificateLifetimeMonths={Months}", config.Security.CertificateLifetimeMonths);
|
|
||||||
|
|
||||||
var unknownProfiles = config.Security.Profiles
|
|
||||||
.Where(p => !SecurityProfileResolver.ValidProfileNames.Contains(p, StringComparer.OrdinalIgnoreCase))
|
|
||||||
.ToList();
|
|
||||||
if (unknownProfiles.Count > 0)
|
|
||||||
Log.Warning("Unknown security profile(s): {Profiles}. Valid values: {ValidProfiles}",
|
|
||||||
string.Join(", ", unknownProfiles), string.Join(", ", SecurityProfileResolver.ValidProfileNames));
|
|
||||||
|
|
||||||
if (config.Security.MinimumCertificateKeySize < 2048)
|
|
||||||
{
|
|
||||||
Log.Error("Security.MinimumCertificateKeySize must be at least 2048");
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.Security.AutoAcceptClientCertificates)
|
|
||||||
Log.Warning(
|
|
||||||
"Security.AutoAcceptClientCertificates is enabled — client certificate trust is not enforced. Set to false in production");
|
|
||||||
|
|
||||||
if (config.Security.Profiles.Count == 1 &&
|
|
||||||
config.Security.Profiles[0].Equals("None", StringComparison.OrdinalIgnoreCase))
|
|
||||||
Log.Warning("Only the 'None' security profile is configured — transport security is disabled");
|
|
||||||
|
|
||||||
// Historian
|
|
||||||
var clusterNodes = config.Historian.ServerNames ?? new List<string>();
|
|
||||||
var effectiveNodes = clusterNodes.Count > 0
|
|
||||||
? string.Join(",", clusterNodes)
|
|
||||||
: config.Historian.ServerName;
|
|
||||||
Log.Information(
|
|
||||||
"Historian.Enabled={Enabled}, Nodes=[{Nodes}], IntegratedSecurity={IntegratedSecurity}, Port={Port}",
|
|
||||||
config.Historian.Enabled, effectiveNodes, config.Historian.IntegratedSecurity,
|
|
||||||
config.Historian.Port);
|
|
||||||
Log.Information(
|
|
||||||
"Historian.CommandTimeoutSeconds={Timeout}, MaxValuesPerRead={MaxValues}, FailureCooldownSeconds={Cooldown}, RequestTimeoutSeconds={RequestTimeout}",
|
|
||||||
config.Historian.CommandTimeoutSeconds, config.Historian.MaxValuesPerRead,
|
|
||||||
config.Historian.FailureCooldownSeconds, config.Historian.RequestTimeoutSeconds);
|
|
||||||
|
|
||||||
if (config.Historian.Enabled)
|
|
||||||
{
|
|
||||||
if (clusterNodes.Count == 0 && string.IsNullOrWhiteSpace(config.Historian.ServerName))
|
|
||||||
{
|
|
||||||
Log.Error("Historian.ServerName (or ServerNames) must not be empty when Historian is enabled");
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.Historian.FailureCooldownSeconds < 0)
|
|
||||||
{
|
|
||||||
Log.Error("Historian.FailureCooldownSeconds must be zero or positive");
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.Historian.RequestTimeoutSeconds < 1)
|
|
||||||
{
|
|
||||||
Log.Error("Historian.RequestTimeoutSeconds must be at least 1");
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
else if (config.Historian.RequestTimeoutSeconds < config.Historian.CommandTimeoutSeconds)
|
|
||||||
{
|
|
||||||
Log.Warning(
|
|
||||||
"Historian.RequestTimeoutSeconds={RequestTimeout} is below CommandTimeoutSeconds={CmdTimeout}; outer safety bound may fire before the inner SDK completes its own error path",
|
|
||||||
config.Historian.RequestTimeoutSeconds, config.Historian.CommandTimeoutSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clusterNodes.Count > 0 && !string.IsNullOrWhiteSpace(config.Historian.ServerName)
|
|
||||||
&& config.Historian.ServerName != "localhost")
|
|
||||||
Log.Warning(
|
|
||||||
"Historian.ServerName='{ServerName}' is ignored because Historian.ServerNames has {Count} entries",
|
|
||||||
config.Historian.ServerName, clusterNodes.Count);
|
|
||||||
|
|
||||||
if (config.Historian.Port < 1 || config.Historian.Port > 65535)
|
|
||||||
{
|
|
||||||
Log.Error("Historian.Port must be between 1 and 65535");
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config.Historian.IntegratedSecurity && string.IsNullOrWhiteSpace(config.Historian.UserName))
|
|
||||||
{
|
|
||||||
Log.Error("Historian.UserName must not be empty when IntegratedSecurity is disabled");
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config.Historian.IntegratedSecurity && string.IsNullOrWhiteSpace(config.Historian.Password))
|
|
||||||
Log.Warning("Historian.Password is empty — authentication may fail");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authentication
|
|
||||||
Log.Information("Authentication.AllowAnonymous={AllowAnonymous}, AnonymousCanWrite={AnonymousCanWrite}",
|
|
||||||
config.Authentication.AllowAnonymous, config.Authentication.AnonymousCanWrite);
|
|
||||||
|
|
||||||
if (config.Authentication.Ldap.Enabled)
|
|
||||||
{
|
|
||||||
Log.Information("Authentication.Ldap.Enabled=true, Host={Host}, Port={Port}, BaseDN={BaseDN}",
|
|
||||||
config.Authentication.Ldap.Host, config.Authentication.Ldap.Port,
|
|
||||||
config.Authentication.Ldap.BaseDN);
|
|
||||||
Log.Information(
|
|
||||||
"Authentication.Ldap groups: ReadOnly={ReadOnly}, WriteOperate={WriteOperate}, WriteTune={WriteTune}, WriteConfigure={WriteConfigure}, AlarmAck={AlarmAck}",
|
|
||||||
config.Authentication.Ldap.ReadOnlyGroup, config.Authentication.Ldap.WriteOperateGroup,
|
|
||||||
config.Authentication.Ldap.WriteTuneGroup, config.Authentication.Ldap.WriteConfigureGroup,
|
|
||||||
config.Authentication.Ldap.AlarmAckGroup);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn))
|
|
||||||
Log.Warning("Authentication.Ldap.ServiceAccountDn is empty — group lookups will fail");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 == 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string SanitizeConnectionString(string connectionString)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(connectionString))
|
|
||||||
return "(empty)";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var builder = new SqlConnectionStringBuilder(connectionString);
|
|
||||||
if (!string.IsNullOrEmpty(builder.Password))
|
|
||||||
builder.Password = "********";
|
|
||||||
return builder.ConnectionString;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return "(unparseable)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Status dashboard configuration. (SVC-003, DASH-001)
|
|
||||||
/// </summary>
|
|
||||||
public class DashboardConfiguration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the operator dashboard is hosted alongside the OPC UA service.
|
|
||||||
/// </summary>
|
|
||||||
public bool Enabled { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the HTTP port used by the dashboard endpoint that exposes service health and rebuild state.
|
|
||||||
/// </summary>
|
|
||||||
public int Port { get; set; } = 8081;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the refresh interval, in seconds, for recalculating the dashboard status snapshot.
|
|
||||||
/// </summary>
|
|
||||||
public int RefreshIntervalSeconds { get; set; } = 10;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Galaxy repository database configuration. (SVC-003, GR-005)
|
|
||||||
/// </summary>
|
|
||||||
public class GalaxyRepositoryConfiguration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the database connection string used to read Galaxy hierarchy and attribute metadata.
|
|
||||||
/// </summary>
|
|
||||||
public string ConnectionString { get; set; } = "Server=localhost;Database=ZB;Integrated Security=true;";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets how often, in seconds, the service polls for Galaxy deploy changes that require an address-space
|
|
||||||
/// rebuild.
|
|
||||||
/// </summary>
|
|
||||||
public int ChangeDetectionIntervalSeconds { get; set; } = 30;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the SQL command timeout, in seconds, for repository queries against the Galaxy catalog.
|
|
||||||
/// </summary>
|
|
||||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether extended Galaxy attribute metadata should be loaded into the OPC UA model.
|
|
||||||
/// </summary>
|
|
||||||
public bool ExtendedAttributes { get; set; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the scope of Galaxy objects loaded into the OPC UA address space.
|
|
||||||
/// <c>Galaxy</c> loads all deployed objects (default). <c>LocalPlatform</c> loads only
|
|
||||||
/// objects hosted by the platform deployed on this machine.
|
|
||||||
/// </summary>
|
|
||||||
public GalaxyScope Scope { get; set; } = GalaxyScope.Galaxy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets an explicit platform node name for <see cref="GalaxyScope.LocalPlatform" /> filtering.
|
|
||||||
/// When <see langword="null" />, the local machine name (<c>Environment.MachineName</c>) is used.
|
|
||||||
/// </summary>
|
|
||||||
public string? PlatformName { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Controls how much of the Galaxy object hierarchy is loaded into the OPC UA address space.
|
|
||||||
/// </summary>
|
|
||||||
public enum GalaxyScope
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Load all deployed objects from the entire Galaxy (default, backward-compatible behavior).
|
|
||||||
/// </summary>
|
|
||||||
Galaxy,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Load only objects hosted by the local platform and the structural areas needed to reach them.
|
|
||||||
/// </summary>
|
|
||||||
LocalPlatform
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Wonderware Historian SDK configuration for OPC UA historical data access.
|
|
||||||
/// </summary>
|
|
||||||
public class HistorianConfiguration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether OPC UA historical data access is enabled.
|
|
||||||
/// </summary>
|
|
||||||
public bool Enabled { get; set; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the single Historian server hostname used when <see cref="ServerNames"/>
|
|
||||||
/// is empty. Preserved for backward compatibility with pre-cluster deployments.
|
|
||||||
/// </summary>
|
|
||||||
public string ServerName { get; set; } = "localhost";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the ordered list of Historian cluster nodes. When non-empty, this list
|
|
||||||
/// supersedes <see cref="ServerName"/>: the data source attempts each node in order on
|
|
||||||
/// connect, falling through to the next on failure. A failed node is placed in cooldown
|
|
||||||
/// for <see cref="FailureCooldownSeconds"/> before being re-eligible.
|
|
||||||
/// </summary>
|
|
||||||
public List<string> ServerNames { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the cooldown window, in seconds, that a historian node is skipped after
|
|
||||||
/// a connection failure. A value of zero retries the node on every request. Default 60s.
|
|
||||||
/// </summary>
|
|
||||||
public int FailureCooldownSeconds { get; set; } = 60;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether Windows Integrated Security is used.
|
|
||||||
/// When false, <see cref="UserName"/> and <see cref="Password"/> are used instead.
|
|
||||||
/// </summary>
|
|
||||||
public bool IntegratedSecurity { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the username for Historian authentication when <see cref="IntegratedSecurity"/> is false.
|
|
||||||
/// </summary>
|
|
||||||
public string? UserName { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the password for Historian authentication when <see cref="IntegratedSecurity"/> is false.
|
|
||||||
/// </summary>
|
|
||||||
public string? Password { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Historian server TCP port.
|
|
||||||
/// </summary>
|
|
||||||
public int Port { get; set; } = 32568;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the packet timeout in seconds for Historian SDK operations.
|
|
||||||
/// </summary>
|
|
||||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the maximum number of values returned per HistoryRead request.
|
|
||||||
/// </summary>
|
|
||||||
public int MaxValuesPerRead { get; set; } = 10000;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets an outer safety timeout, in seconds, applied to sync-over-async Historian
|
|
||||||
/// operations invoked from the OPC UA stack thread (HistoryReadRaw, HistoryReadProcessed,
|
|
||||||
/// HistoryReadAtTime, HistoryReadEvents). This is a backstop for the case where a
|
|
||||||
/// historian query hangs outside <see cref="CommandTimeoutSeconds"/> — e.g., a slow SDK
|
|
||||||
/// reconnect or mid-failover cluster node. Must be comfortably larger than
|
|
||||||
/// <see cref="CommandTimeoutSeconds"/> so normal operation is never affected. Default 60s.
|
|
||||||
/// </summary>
|
|
||||||
public int RequestTimeoutSeconds { get; set; } = 60;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// LDAP authentication and group-to-role mapping settings.
|
|
||||||
/// </summary>
|
|
||||||
public class LdapConfiguration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets whether LDAP authentication is enabled.
|
|
||||||
/// When true, user credentials are validated against the configured LDAP server
|
|
||||||
/// and group membership determines OPC UA permissions.
|
|
||||||
/// </summary>
|
|
||||||
public bool Enabled { get; set; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the LDAP server hostname or IP address.
|
|
||||||
/// </summary>
|
|
||||||
public string Host { get; set; } = "localhost";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the LDAP server port.
|
|
||||||
/// </summary>
|
|
||||||
public int Port { get; set; } = 3893;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the base DN for LDAP operations.
|
|
||||||
/// </summary>
|
|
||||||
public string BaseDN { get; set; } = "dc=lmxopcua,dc=local";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the bind DN template. Use {username} as a placeholder.
|
|
||||||
/// </summary>
|
|
||||||
public string BindDnTemplate { get; set; } = "cn={username},dc=lmxopcua,dc=local";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the service account DN used for LDAP searches (group lookups).
|
|
||||||
/// </summary>
|
|
||||||
public string ServiceAccountDn { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the service account password.
|
|
||||||
/// </summary>
|
|
||||||
public string ServiceAccountPassword { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the LDAP connection timeout in seconds.
|
|
||||||
/// </summary>
|
|
||||||
public int TimeoutSeconds { get; set; } = 5;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the LDAP group name that grants read-only access.
|
|
||||||
/// </summary>
|
|
||||||
public string ReadOnlyGroup { get; set; } = "ReadOnly";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the LDAP group name that grants write access for FreeAccess/Operate attributes.
|
|
||||||
/// </summary>
|
|
||||||
public string WriteOperateGroup { get; set; } = "WriteOperate";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the LDAP group name that grants write access for Tune attributes.
|
|
||||||
/// </summary>
|
|
||||||
public string WriteTuneGroup { get; set; } = "WriteTune";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the LDAP group name that grants write access for Configure attributes.
|
|
||||||
/// </summary>
|
|
||||||
public string WriteConfigureGroup { get; set; } = "WriteConfigure";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the LDAP group name that grants alarm acknowledgment access.
|
|
||||||
/// </summary>
|
|
||||||
public string AlarmAckGroup { get; set; } = "AlarmAck";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// MXAccess client configuration. (SVC-003, MXA-008, MXA-009)
|
|
||||||
/// </summary>
|
|
||||||
public class MxAccessConfiguration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the client name registered with the MXAccess runtime for this bridge instance.
|
|
||||||
/// </summary>
|
|
||||||
public string ClientName { get; set; } = "LmxOpcUa";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy node name to target when the service connects to a specific runtime node.
|
|
||||||
/// </summary>
|
|
||||||
public string? NodeName { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy name used when resolving MXAccess references and diagnostics.
|
|
||||||
/// </summary>
|
|
||||||
public string? GalaxyName { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the maximum time, in seconds, to wait for a live tag read to complete.
|
|
||||||
/// </summary>
|
|
||||||
public int ReadTimeoutSeconds { get; set; } = 5;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the maximum time, in seconds, to wait for a tag write acknowledgment from the runtime.
|
|
||||||
/// </summary>
|
|
||||||
public int WriteTimeoutSeconds { get; set; } = 5;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets an outer safety timeout, in seconds, applied to sync-over-async MxAccess
|
|
||||||
/// operations invoked from the OPC UA stack thread (Read, Write, address-space rebuild probe
|
|
||||||
/// sync). This is a backstop for the case where an async path hangs outside the inner
|
|
||||||
/// <see cref="ReadTimeoutSeconds"/> / <see cref="WriteTimeoutSeconds"/> bounds — e.g., a
|
|
||||||
/// slow reconnect or a scheduler stall. Must be comfortably larger than the inner timeouts
|
|
||||||
/// so normal operation is never affected. Default 30s.
|
|
||||||
/// </summary>
|
|
||||||
public int RequestTimeoutSeconds { get; set; } = 30;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the cap on concurrent MXAccess operations so the bridge does not overload the runtime.
|
|
||||||
/// </summary>
|
|
||||||
public int MaxConcurrentOperations { get; set; } = 10;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets how often, in seconds, the connectivity monitor probes the runtime connection.
|
|
||||||
/// </summary>
|
|
||||||
public int MonitorIntervalSeconds { get; set; } = 5;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the bridge should automatically attempt to re-establish a dropped MXAccess
|
|
||||||
/// session.
|
|
||||||
/// </summary>
|
|
||||||
public bool AutoReconnect { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the optional probe tag used to verify that the MXAccess runtime is still returning fresh data.
|
|
||||||
/// </summary>
|
|
||||||
public string? ProbeTag { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of seconds a probe value may remain unchanged before the connection is considered stale.
|
|
||||||
/// </summary>
|
|
||||||
public int ProbeStaleThresholdSeconds { get; set; } = 60;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the bridge advises <c><ObjectName>.ScanState</c> for every
|
|
||||||
/// deployed <c>$WinPlatform</c> and <c>$AppEngine</c>, reporting per-host runtime state on the status
|
|
||||||
/// dashboard and proactively invalidating OPC UA variable quality when a host transitions to Stopped.
|
|
||||||
/// Enabled by default. Disable to return to legacy behavior where host runtime state is invisible and
|
|
||||||
/// MxAccess's per-tag bad-quality fan-out is the only stop signal.
|
|
||||||
/// </summary>
|
|
||||||
public bool RuntimeStatusProbesEnabled { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the maximum seconds to wait for the initial probe callback before marking a host as
|
|
||||||
/// Stopped. Only applies to the Unknown → Stopped transition. Because <c>ScanState</c> is delivered
|
|
||||||
/// on-change only, a stably Running host does not time out — no starvation check runs on Running
|
|
||||||
/// entries. Default 15s.
|
|
||||||
/// </summary>
|
|
||||||
public int RuntimeStatusUnknownTimeoutSeconds { get; set; } = 15;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// OPC UA server configuration. (SVC-003, OPC-001, OPC-012, OPC-013)
|
|
||||||
/// </summary>
|
|
||||||
public class OpcUaConfiguration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the IP address or hostname the OPC UA server binds to.
|
|
||||||
/// Defaults to <c>0.0.0.0</c> (all interfaces). Set to a specific IP or hostname to restrict listening.
|
|
||||||
/// </summary>
|
|
||||||
public string BindAddress { get; set; } = "0.0.0.0";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the TCP port on which the OPC UA server listens for client sessions.
|
|
||||||
/// </summary>
|
|
||||||
public int Port { get; set; } = 4840;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the endpoint path appended to the host URI for the LMX OPC UA server.
|
|
||||||
/// </summary>
|
|
||||||
public string EndpointPath { get; set; } = "/LmxOpcUa";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the server name presented to OPC UA clients and used in diagnostics.
|
|
||||||
/// </summary>
|
|
||||||
public string ServerName { get; set; } = "LmxOpcUa";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy name represented by the published OPC UA namespace.
|
|
||||||
/// </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>
|
|
||||||
public int MaxSessions { get; set; } = 100;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the session timeout, in minutes, before idle client sessions are closed.
|
|
||||||
/// </summary>
|
|
||||||
public int SessionTimeoutMinutes { get; set; } = 30;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether alarm tracking is enabled.
|
|
||||||
/// When enabled, AlarmConditionState nodes are created for alarm attributes and InAlarm transitions are monitored.
|
|
||||||
/// </summary>
|
|
||||||
public bool AlarmTrackingEnabled { get; set; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the template-based alarm object filter. When <see cref="AlarmFilterConfiguration.ObjectFilters"/>
|
|
||||||
/// is empty, all alarm-bearing objects are monitored (current behavior). When patterns are supplied, only
|
|
||||||
/// objects whose template derivation chain matches a pattern (and their descendants) have alarms monitored.
|
|
||||||
/// </summary>
|
|
||||||
public AlarmFilterConfiguration AlarmFilter { get; set; } = new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.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();
|
|
||||||
|
|
||||||
/// <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,52 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Transport security settings that control which OPC UA security profiles the server exposes and how client
|
|
||||||
/// certificates are handled.
|
|
||||||
/// </summary>
|
|
||||||
public class SecurityProfileConfiguration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the list of security profile names to expose as server endpoints.
|
|
||||||
/// Valid values: "None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt".
|
|
||||||
/// Defaults to ["None"] for backward compatibility.
|
|
||||||
/// </summary>
|
|
||||||
public List<string> Profiles { get; set; } = new() { "None" };
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the server automatically accepts client certificates
|
|
||||||
/// that are not in the trusted store. Should be <see langword="false" /> in production.
|
|
||||||
/// </summary>
|
|
||||||
public bool AutoAcceptClientCertificates { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether client certificates signed with SHA-1 are rejected.
|
|
||||||
/// </summary>
|
|
||||||
public bool RejectSHA1Certificates { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the minimum RSA key size required for client certificates.
|
|
||||||
/// </summary>
|
|
||||||
public int MinimumCertificateKeySize { get; set; } = 2048;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets an optional override for the PKI root directory.
|
|
||||||
/// When <see langword="null" />, defaults to <c>%LOCALAPPDATA%\OPC Foundation\pki</c>.
|
|
||||||
/// </summary>
|
|
||||||
public string? PkiRootPath { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets an optional override for the server certificate subject name.
|
|
||||||
/// When <see langword="null" />, defaults to <c>CN={ServerName}, O=ZB MOM, DC=localhost</c>.
|
|
||||||
/// </summary>
|
|
||||||
public string? CertificateSubject { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the lifetime of the auto-generated server certificate in months.
|
|
||||||
/// Defaults to 60 months (5 years).
|
|
||||||
/// </summary>
|
|
||||||
public int CertificateLifetimeMonths { get; set; } = 60;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Compiles and applies wildcard template patterns against Galaxy objects to decide which
|
|
||||||
/// objects should contribute alarm conditions. The filter is pure data — no OPC UA, no DB —
|
|
||||||
/// so it is fully unit-testable with synthetic hierarchies.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>Matching rules:</para>
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>An object is included when any template name in its derivation chain matches
|
|
||||||
/// any configured pattern.</item>
|
|
||||||
/// <item>Matching is case-insensitive and ignores the Galaxy leading <c>$</c> prefix on
|
|
||||||
/// both the chain entry and the user pattern, so <c>TestMachine*</c> matches the stored
|
|
||||||
/// <c>$TestMachine</c>.</item>
|
|
||||||
/// <item>Inclusion propagates to every descendant of a matched object (containment subtree).</item>
|
|
||||||
/// <item>Each object is evaluated once — overlapping matches never produce duplicate
|
|
||||||
/// inclusions (set semantics).</item>
|
|
||||||
/// </list>
|
|
||||||
/// <para>Pattern syntax: literal text plus <c>*</c> wildcards (zero or more characters).
|
|
||||||
/// Other regex metacharacters in the raw pattern are escaped and treated literally.</para>
|
|
||||||
/// </remarks>
|
|
||||||
public class AlarmObjectFilter
|
|
||||||
{
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<AlarmObjectFilter>();
|
|
||||||
|
|
||||||
private readonly List<Regex> _patterns;
|
|
||||||
private readonly List<string> _rawPatterns;
|
|
||||||
private readonly HashSet<string> _matchedRawPatterns;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new alarm object filter from the supplied configuration section.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="config">The alarm filter configuration whose <see cref="AlarmFilterConfiguration.ObjectFilters"/>
|
|
||||||
/// entries are parsed into regular expressions. Entries may themselves contain comma-separated patterns.</param>
|
|
||||||
public AlarmObjectFilter(AlarmFilterConfiguration? config)
|
|
||||||
{
|
|
||||||
_patterns = new List<Regex>();
|
|
||||||
_rawPatterns = new List<string>();
|
|
||||||
_matchedRawPatterns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
if (config?.ObjectFilters == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
foreach (var entry in config.ObjectFilters)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(entry))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
foreach (var piece in entry.Split(','))
|
|
||||||
{
|
|
||||||
var trimmed = piece.Trim();
|
|
||||||
if (trimmed.Length == 0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var normalized = Normalize(trimmed);
|
|
||||||
var regex = GlobToRegex(normalized);
|
|
||||||
_patterns.Add(regex);
|
|
||||||
_rawPatterns.Add(trimmed);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Failed to compile alarm filter pattern {Pattern} — skipping", trimmed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a value indicating whether the filter has any compiled patterns. When <see langword="false"/>,
|
|
||||||
/// callers should treat alarm tracking as unfiltered (current behavior preserved).
|
|
||||||
/// </summary>
|
|
||||||
public bool Enabled => _patterns.Count > 0;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of compiled patterns the filter will evaluate against each object.
|
|
||||||
/// </summary>
|
|
||||||
public int PatternCount => _patterns.Count;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the raw pattern strings that did not match any object in the most recent call to
|
|
||||||
/// <see cref="ResolveIncludedObjects"/>. Useful for startup warnings about operator typos.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<string> UnmatchedPatterns =>
|
|
||||||
_rawPatterns.Where(p => !_matchedRawPatterns.Contains(p)).ToList();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the raw pattern strings exactly as supplied by the operator after comma-splitting
|
|
||||||
/// and trimming. Surfaced on the status dashboard so operators can confirm the active filter.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<string> RawPatterns => _rawPatterns;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns <see langword="true"/> when any template name in <paramref name="chain"/> matches any
|
|
||||||
/// compiled pattern. An empty chain never matches unless the operator explicitly supplied a pattern
|
|
||||||
/// equal to <c>*</c> (which collapses to an empty-matching regex after normalization).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="chain">The template derivation chain to test (own template first, ancestors after).</param>
|
|
||||||
public bool MatchesTemplateChain(IReadOnlyList<string>? chain)
|
|
||||||
{
|
|
||||||
if (chain == null || chain.Count == 0 || _patterns.Count == 0)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
for (var i = 0; i < _patterns.Count; i++)
|
|
||||||
{
|
|
||||||
var regex = _patterns[i];
|
|
||||||
for (var j = 0; j < chain.Count; j++)
|
|
||||||
{
|
|
||||||
var entry = chain[j];
|
|
||||||
if (string.IsNullOrEmpty(entry))
|
|
||||||
continue;
|
|
||||||
if (regex.IsMatch(Normalize(entry)))
|
|
||||||
{
|
|
||||||
_matchedRawPatterns.Add(_rawPatterns[i]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Walks the hierarchy top-down from each root and returns the set of gobject IDs whose alarms
|
|
||||||
/// should be monitored, honoring both template matching and descendant propagation. Returns
|
|
||||||
/// <see langword="null"/> when the filter is disabled so callers can skip the containment check
|
|
||||||
/// entirely.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="hierarchy">The full deployed Galaxy hierarchy, as returned by the repository service.</param>
|
|
||||||
/// <returns>The set of included gobject IDs, or <see langword="null"/> when filtering is disabled.</returns>
|
|
||||||
public HashSet<int>? ResolveIncludedObjects(IReadOnlyList<GalaxyObjectInfo>? hierarchy)
|
|
||||||
{
|
|
||||||
if (!Enabled)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
_matchedRawPatterns.Clear();
|
|
||||||
var included = new HashSet<int>();
|
|
||||||
if (hierarchy == null || hierarchy.Count == 0)
|
|
||||||
return included;
|
|
||||||
|
|
||||||
var byId = new Dictionary<int, GalaxyObjectInfo>(hierarchy.Count);
|
|
||||||
foreach (var obj in hierarchy)
|
|
||||||
byId[obj.GobjectId] = obj;
|
|
||||||
|
|
||||||
var childrenByParent = new Dictionary<int, List<int>>();
|
|
||||||
foreach (var obj in hierarchy)
|
|
||||||
{
|
|
||||||
var parentId = obj.ParentGobjectId;
|
|
||||||
if (parentId != 0 && !byId.ContainsKey(parentId))
|
|
||||||
parentId = 0; // orphan → treat as root
|
|
||||||
if (!childrenByParent.TryGetValue(parentId, out var list))
|
|
||||||
{
|
|
||||||
list = new List<int>();
|
|
||||||
childrenByParent[parentId] = list;
|
|
||||||
}
|
|
||||||
list.Add(obj.GobjectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
var roots = childrenByParent.TryGetValue(0, out var rootList)
|
|
||||||
? rootList
|
|
||||||
: new List<int>();
|
|
||||||
|
|
||||||
var visited = new HashSet<int>();
|
|
||||||
var queue = new Queue<(int Id, bool ParentIncluded)>();
|
|
||||||
foreach (var rootId in roots)
|
|
||||||
queue.Enqueue((rootId, false));
|
|
||||||
|
|
||||||
while (queue.Count > 0)
|
|
||||||
{
|
|
||||||
var (id, parentIncluded) = queue.Dequeue();
|
|
||||||
if (!visited.Add(id))
|
|
||||||
continue; // cycle defense
|
|
||||||
|
|
||||||
if (!byId.TryGetValue(id, out var obj))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var nodeIncluded = parentIncluded || MatchesTemplateChain(obj.TemplateChain);
|
|
||||||
if (nodeIncluded)
|
|
||||||
included.Add(id);
|
|
||||||
|
|
||||||
if (childrenByParent.TryGetValue(id, out var children))
|
|
||||||
foreach (var childId in children)
|
|
||||||
queue.Enqueue((childId, nodeIncluded));
|
|
||||||
}
|
|
||||||
|
|
||||||
return included;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Regex GlobToRegex(string normalized)
|
|
||||||
{
|
|
||||||
var segments = normalized.Split('*');
|
|
||||||
var parts = segments.Select(Regex.Escape);
|
|
||||||
var body = string.Join(".*", parts);
|
|
||||||
return new Regex("^" + body + "$",
|
|
||||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Normalize(string value)
|
|
||||||
{
|
|
||||||
var trimmed = value.Trim();
|
|
||||||
if (trimmed.StartsWith("$", StringComparison.Ordinal))
|
|
||||||
return trimmed.Substring(1);
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// MXAccess connection lifecycle states. (MXA-002)
|
|
||||||
/// </summary>
|
|
||||||
public enum ConnectionState
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// No active session exists to the Galaxy runtime.
|
|
||||||
/// </summary>
|
|
||||||
Disconnected,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The bridge is opening a new MXAccess session to the runtime.
|
|
||||||
/// </summary>
|
|
||||||
Connecting,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The bridge has an active MXAccess session and can service reads, writes, and subscriptions.
|
|
||||||
/// </summary>
|
|
||||||
Connected,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The bridge is closing the current MXAccess session and draining runtime resources.
|
|
||||||
/// </summary>
|
|
||||||
Disconnecting,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The bridge detected a connection fault that requires operator attention or recovery logic.
|
|
||||||
/// </summary>
|
|
||||||
Error,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The bridge is attempting to restore service after a runtime communication failure.
|
|
||||||
/// </summary>
|
|
||||||
Reconnecting
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Event args for connection state transitions. (MXA-002)
|
|
||||||
/// </summary>
|
|
||||||
public class ConnectionStateChangedEventArgs : EventArgs
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="ConnectionStateChangedEventArgs" /> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="previous">The connection state being exited.</param>
|
|
||||||
/// <param name="current">The connection state being entered.</param>
|
|
||||||
/// <param name="message">Additional context about the transition, such as a connection fault or reconnect attempt.</param>
|
|
||||||
public ConnectionStateChangedEventArgs(ConnectionState previous, ConnectionState current, string message = "")
|
|
||||||
{
|
|
||||||
PreviousState = previous;
|
|
||||||
CurrentState = current;
|
|
||||||
Message = message ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the previous MXAccess connection state before the transition was raised.
|
|
||||||
/// </summary>
|
|
||||||
public ConnectionState PreviousState { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the new MXAccess connection state that the bridge moved into.
|
|
||||||
/// </summary>
|
|
||||||
public ConnectionState CurrentState { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets an operator-facing message that explains why the connection state changed.
|
|
||||||
/// </summary>
|
|
||||||
public string Message { get; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// DTO matching attributes.sql result columns. (GR-002)
|
|
||||||
/// </summary>
|
|
||||||
public class GalaxyAttributeInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy object identifier that owns the attribute.
|
|
||||||
/// </summary>
|
|
||||||
public int GobjectId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Wonderware tag name used to associate the attribute with its runtime object.
|
|
||||||
/// </summary>
|
|
||||||
public string TagName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the attribute name as defined on the Galaxy template or instance.
|
|
||||||
/// </summary>
|
|
||||||
public string AttributeName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the fully qualified MXAccess reference used for runtime reads and writes.
|
|
||||||
/// </summary>
|
|
||||||
public string FullTagReference { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the numeric Galaxy data type code used to map the attribute into OPC UA.
|
|
||||||
/// </summary>
|
|
||||||
public int MxDataType { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the human-readable Galaxy data type name returned by the repository query.
|
|
||||||
/// </summary>
|
|
||||||
public string DataTypeName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the attribute is an array and should be exposed as a collection node.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsArray { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the array length when the Galaxy attribute is modeled as a fixed-size array.
|
|
||||||
/// </summary>
|
|
||||||
public int? ArrayDimension { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the primitive data type name used when flattening the attribute for OPC UA clients.
|
|
||||||
/// </summary>
|
|
||||||
public string PrimitiveName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the source classification that explains whether the attribute comes from configuration, calculation,
|
|
||||||
/// or runtime data.
|
|
||||||
/// </summary>
|
|
||||||
public string AttributeSource { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy security classification that determines OPC UA write access.
|
|
||||||
/// 0=FreeAccess, 1=Operate (default), 2=SecuredWrite, 3=VerifiedWrite, 4=Tune, 5=Configure, 6=ViewOnly.
|
|
||||||
/// </summary>
|
|
||||||
public int SecurityClassification { get; set; } = 1;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the attribute has a HistoryExtension primitive and is historized by the
|
|
||||||
/// Wonderware Historian.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsHistorized { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the attribute has an AlarmExtension primitive and is an alarm.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsAlarm { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// DTO matching hierarchy.sql result columns. (GR-001)
|
|
||||||
/// </summary>
|
|
||||||
public class GalaxyObjectInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy object identifier used to connect hierarchy rows to attribute rows.
|
|
||||||
/// </summary>
|
|
||||||
public int GobjectId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the runtime tag name for the Galaxy object represented in the OPC UA tree.
|
|
||||||
/// </summary>
|
|
||||||
public string TagName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the contained name shown for the object inside its parent area or object.
|
|
||||||
/// </summary>
|
|
||||||
public string ContainedName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the browse name emitted into OPC UA so clients can navigate the Galaxy hierarchy.
|
|
||||||
/// </summary>
|
|
||||||
public string BrowseName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the parent Galaxy object identifier that establishes the hierarchy relationship.
|
|
||||||
/// </summary>
|
|
||||||
public int ParentGobjectId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the row represents a Galaxy area rather than a contained object.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsArea { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the template derivation chain for this object. Index 0 is the object's own template;
|
|
||||||
/// subsequent entries walk up toward the most ancestral template before <c>$Object</c>. Populated by
|
|
||||||
/// the recursive CTE in <c>hierarchy.sql</c> on <c>gobject.derived_from_gobject_id</c>. Used by
|
|
||||||
/// <see cref="AlarmObjectFilter"/> to decide whether an object's alarms should be monitored.
|
|
||||||
/// </summary>
|
|
||||||
public List<string> TemplateChain { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy template category id for this object. Category 1 is $WinPlatform,
|
|
||||||
/// 3 is $AppEngine, 13 is $Area, 10 is $UserDefined, and so on. Populated from
|
|
||||||
/// <c>template_definition.category_id</c> by <c>hierarchy.sql</c> and consumed by the runtime
|
|
||||||
/// status probe manager to identify hosts that should receive a <c>ScanState</c> probe.
|
|
||||||
/// </summary>
|
|
||||||
public int CategoryId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy object id of this object's runtime host, populated from
|
|
||||||
/// <c>gobject.hosted_by_gobject_id</c>. Walk this chain upward to find the nearest
|
|
||||||
/// <c>$WinPlatform</c> or <c>$AppEngine</c> ancestor for subtree quality invalidation when
|
|
||||||
/// a runtime host is reported Stopped. Zero for root objects that have no host.
|
|
||||||
/// </summary>
|
|
||||||
public int HostedByGobjectId { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Runtime state of a deployed Galaxy runtime host ($WinPlatform or $AppEngine) as
|
|
||||||
/// observed by the bridge via its <c>ScanState</c> probe.
|
|
||||||
/// </summary>
|
|
||||||
public enum GalaxyRuntimeState
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Probe advised but no callback received yet. Transitions to <see cref="Running"/>
|
|
||||||
/// on the first successful <c>ScanState = true</c> callback, or to <see cref="Stopped"/>
|
|
||||||
/// once the unknown-resolution timeout elapses.
|
|
||||||
/// </summary>
|
|
||||||
Unknown,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Last probe callback reported <c>ScanState = true</c> with a successful item status.
|
|
||||||
/// The host is on scan and executing.
|
|
||||||
/// </summary>
|
|
||||||
Running,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Last probe callback reported <c>ScanState != true</c>, or a failed item status, or
|
|
||||||
/// the initial probe never resolved before the unknown timeout elapsed. The host is
|
|
||||||
/// off scan or unreachable.
|
|
||||||
/// </summary>
|
|
||||||
Stopped
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Point-in-time runtime state of a single Galaxy runtime host ($WinPlatform or $AppEngine)
|
|
||||||
/// as tracked by the <c>GalaxyRuntimeProbeManager</c>. Surfaced on the status dashboard and
|
|
||||||
/// consumed by <c>HealthCheckService</c> so operators can detect a stopped host before
|
|
||||||
/// downstream clients notice the stale data.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GalaxyRuntimeStatus
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy tag_name of the host (e.g., <c>DevPlatform</c> or
|
|
||||||
/// <c>DevAppEngine</c>).
|
|
||||||
/// </summary>
|
|
||||||
public string ObjectName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy gobject_id of the host.
|
|
||||||
/// </summary>
|
|
||||||
public int GobjectId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy template category name — <c>$WinPlatform</c> or
|
|
||||||
/// <c>$AppEngine</c>. Used by the dashboard to group hosts by kind.
|
|
||||||
/// </summary>
|
|
||||||
public string Kind { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the current runtime state.
|
|
||||||
/// </summary>
|
|
||||||
public GalaxyRuntimeState State { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the UTC timestamp of the most recent probe callback, whether it
|
|
||||||
/// reported success or failure. <see langword="null"/> before the first callback.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? LastStateCallbackTime { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the UTC timestamp of the most recent <see cref="State"/> transition.
|
|
||||||
/// Backs the dashboard "Since" column. <see langword="null"/> in the initial Unknown
|
|
||||||
/// state before any transition.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? LastStateChangeTime { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the last <c>ScanState</c> value received from the probe, or
|
|
||||||
/// <see langword="null"/> before the first update or when the last callback carried
|
|
||||||
/// a non-success item status (no value delivered).
|
|
||||||
/// </summary>
|
|
||||||
public bool? LastScanState { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the detail message from the most recent failure callback, cleared on
|
|
||||||
/// the next successful <c>ScanState = true</c> delivery.
|
|
||||||
/// </summary>
|
|
||||||
public string? LastError { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the cumulative number of callbacks where <c>ScanState = true</c>.
|
|
||||||
/// </summary>
|
|
||||||
public long GoodUpdateCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the cumulative number of callbacks where <c>ScanState != true</c>
|
|
||||||
/// or the item status reported failure.
|
|
||||||
/// </summary>
|
|
||||||
public long FailureCount { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Interface for Galaxy repository database queries. (GR-001 through GR-004)
|
|
||||||
/// </summary>
|
|
||||||
public interface IGalaxyRepository
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves the Galaxy object hierarchy used to construct the OPC UA browse tree.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A token that cancels the repository query.</param>
|
|
||||||
/// <returns>A list of Galaxy objects ordered for address-space construction.</returns>
|
|
||||||
Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves the Galaxy attributes that become OPC UA variables under the object hierarchy.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A token that cancels the repository query.</param>
|
|
||||||
/// <returns>A list of attribute definitions with MXAccess references and type metadata.</returns>
|
|
||||||
Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the last Galaxy deploy timestamp used to detect metadata changes that require an address-space rebuild.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A token that cancels the repository query.</param>
|
|
||||||
/// <returns>The latest deploy timestamp, or <see langword="null" /> when it cannot be determined.</returns>
|
|
||||||
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verifies that the service can reach the Galaxy repository before it attempts to build the address space.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A token that cancels the connectivity check.</param>
|
|
||||||
/// <returns><see langword="true" /> when repository access succeeds; otherwise, <see langword="false" />.</returns>
|
|
||||||
Task<bool> TestConnectionAsync(CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when the repository detects a Galaxy deployment change that should trigger an OPC UA rebuild.
|
|
||||||
/// </summary>
|
|
||||||
event Action? OnGalaxyChanged;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Abstraction over MXAccess COM client for tag read/write/subscribe operations.
|
|
||||||
/// (MXA-001 through MXA-009, OPC-007, OPC-008, OPC-009)
|
|
||||||
/// </summary>
|
|
||||||
public interface IMxAccessClient : IDisposable
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current runtime connectivity state for the bridge.
|
|
||||||
/// </summary>
|
|
||||||
ConnectionState State { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of active runtime subscriptions currently being mirrored into OPC UA.
|
|
||||||
/// </summary>
|
|
||||||
int ActiveSubscriptionCount { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of reconnect cycles attempted since the client was created.
|
|
||||||
/// </summary>
|
|
||||||
int ReconnectCount { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when the MXAccess session changes state so the host can update diagnostics and retry logic.
|
|
||||||
/// </summary>
|
|
||||||
event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when a subscribed Galaxy attribute publishes a new runtime value.
|
|
||||||
/// </summary>
|
|
||||||
event Action<string, Vtq>? OnTagValueChanged;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Opens the MXAccess session required for runtime reads, writes, and subscriptions.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A token that cancels the connection attempt.</param>
|
|
||||||
Task ConnectAsync(CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Closes the MXAccess session and releases runtime resources.
|
|
||||||
/// </summary>
|
|
||||||
Task DisconnectAsync();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Starts monitoring a Galaxy attribute so value changes can be pushed to OPC UA subscribers.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
|
|
||||||
/// <param name="callback">The callback to invoke when the runtime publishes a new value for the attribute.</param>
|
|
||||||
Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops monitoring a Galaxy attribute when it is no longer needed by the OPC UA layer.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
|
|
||||||
Task UnsubscribeAsync(string fullTagReference);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads the current runtime value for a Galaxy attribute.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
|
|
||||||
/// <param name="ct">A token that cancels the read.</param>
|
|
||||||
/// <returns>The value, timestamp, and quality returned by the runtime.</returns>
|
|
||||||
Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes a new runtime value to a writable Galaxy attribute.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fullTagReference">The fully qualified MXAccess reference for the target attribute.</param>
|
|
||||||
/// <param name="value">The value to write to the runtime.</param>
|
|
||||||
/// <param name="ct">A token that cancels the write.</param>
|
|
||||||
/// <returns><see langword="true" /> when the write is accepted by the runtime; otherwise, <see langword="false" />.</returns>
|
|
||||||
Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
using ArchestrA.MxAccess;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Delegate matching LMXProxyServer.OnDataChange COM event signature.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="hLMXServerHandle">The runtime connection handle that raised the change.</param>
|
|
||||||
/// <param name="phItemHandle">The runtime item handle for the attribute that changed.</param>
|
|
||||||
/// <param name="pvItemValue">The new raw runtime value for the attribute.</param>
|
|
||||||
/// <param name="pwItemQuality">The OPC DA quality code supplied by the runtime.</param>
|
|
||||||
/// <param name="pftItemTimeStamp">The timestamp object supplied by the runtime for the value.</param>
|
|
||||||
/// <param name="ItemStatus">The MXAccess status payload associated with the callback.</param>
|
|
||||||
public delegate void MxDataChangeHandler(
|
|
||||||
int hLMXServerHandle,
|
|
||||||
int phItemHandle,
|
|
||||||
object pvItemValue,
|
|
||||||
int pwItemQuality,
|
|
||||||
object pftItemTimeStamp,
|
|
||||||
ref MXSTATUS_PROXY[] ItemStatus);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Delegate matching LMXProxyServer.OnWriteComplete COM event signature.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="hLMXServerHandle">The runtime connection handle that processed the write.</param>
|
|
||||||
/// <param name="phItemHandle">The runtime item handle that was written.</param>
|
|
||||||
/// <param name="ItemStatus">The MXAccess status payload describing the write outcome.</param>
|
|
||||||
public delegate void MxWriteCompleteHandler(
|
|
||||||
int hLMXServerHandle,
|
|
||||||
int phItemHandle,
|
|
||||||
ref MXSTATUS_PROXY[] ItemStatus);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Abstraction over LMXProxyServer COM object to enable testing without the COM runtime. (MXA-001)
|
|
||||||
/// </summary>
|
|
||||||
public interface IMxProxy
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Registers the bridge as an MXAccess client with the runtime proxy.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="clientName">The client identity reported to the runtime for diagnostics and session tracking.</param>
|
|
||||||
/// <returns>The runtime connection handle assigned to the client session.</returns>
|
|
||||||
int Register(string clientName);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unregisters the bridge from the runtime proxy and releases the connection handle.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="handle">The connection handle returned by <see cref="Register(string)" />.</param>
|
|
||||||
void Unregister(int handle);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds a Galaxy attribute reference to the active runtime session.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="handle">The runtime connection handle.</param>
|
|
||||||
/// <param name="address">The fully qualified attribute reference to resolve.</param>
|
|
||||||
/// <returns>The runtime item handle assigned to the attribute.</returns>
|
|
||||||
int AddItem(int handle, string address);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Removes a previously registered attribute from the runtime session.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="handle">The runtime connection handle.</param>
|
|
||||||
/// <param name="itemHandle">The item handle returned by <see cref="AddItem(int, string)" />.</param>
|
|
||||||
void RemoveItem(int handle, int itemHandle);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Starts supervisory updates for an attribute so runtime changes are pushed to the bridge.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="handle">The runtime connection handle.</param>
|
|
||||||
/// <param name="itemHandle">The item handle to monitor.</param>
|
|
||||||
void AdviseSupervisory(int handle, int itemHandle);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops supervisory updates for an attribute.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="handle">The runtime connection handle.</param>
|
|
||||||
/// <param name="itemHandle">The item handle to stop monitoring.</param>
|
|
||||||
void UnAdviseSupervisory(int handle, int itemHandle);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes a new value to a runtime attribute through the COM proxy.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="handle">The runtime connection handle.</param>
|
|
||||||
/// <param name="itemHandle">The item handle to write.</param>
|
|
||||||
/// <param name="value">The new value to push into the runtime.</param>
|
|
||||||
/// <param name="securityClassification">The Wonderware security classification applied to the write.</param>
|
|
||||||
void Write(int handle, int itemHandle, object value, int securityClassification);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when the runtime pushes a data-change callback for a subscribed attribute.
|
|
||||||
/// </summary>
|
|
||||||
event MxDataChangeHandler? OnDataChange;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when the runtime acknowledges completion of a write request.
|
|
||||||
/// </summary>
|
|
||||||
event MxWriteCompleteHandler? OnWriteComplete;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Pluggable interface for validating user credentials. Implement for different backing stores (config file, LDAP,
|
|
||||||
/// etc.).
|
|
||||||
/// </summary>
|
|
||||||
public interface IUserAuthenticationProvider
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Validates a username/password combination.
|
|
||||||
/// </summary>
|
|
||||||
bool ValidateCredentials(string username, string password);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extended interface for providers that can resolve application-level roles for authenticated users.
|
|
||||||
/// When the auth provider implements this interface, OnImpersonateUser uses the returned roles
|
|
||||||
/// to control write and alarm-ack permissions.
|
|
||||||
/// </summary>
|
|
||||||
public interface IRoleProvider
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the set of application-level roles granted to the user.
|
|
||||||
/// </summary>
|
|
||||||
IReadOnlyList<string> GetUserRoles(string username);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Well-known application-level role names used for permission enforcement.
|
|
||||||
/// </summary>
|
|
||||||
public static class AppRoles
|
|
||||||
{
|
|
||||||
public const string ReadOnly = "ReadOnly";
|
|
||||||
public const string WriteOperate = "WriteOperate";
|
|
||||||
public const string WriteTune = "WriteTune";
|
|
||||||
public const string WriteConfigure = "WriteConfigure";
|
|
||||||
public const string AlarmAck = "AlarmAck";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.DirectoryServices.Protocols;
|
|
||||||
using System.Net;
|
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Validates credentials via LDAP bind and resolves group membership to application roles.
|
|
||||||
/// </summary>
|
|
||||||
public class LdapAuthenticationProvider : IUserAuthenticationProvider, IRoleProvider
|
|
||||||
{
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<LdapAuthenticationProvider>();
|
|
||||||
|
|
||||||
private readonly LdapConfiguration _config;
|
|
||||||
private readonly Dictionary<string, string> _groupToRole;
|
|
||||||
|
|
||||||
public LdapAuthenticationProvider(LdapConfiguration config)
|
|
||||||
{
|
|
||||||
_config = config;
|
|
||||||
_groupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
{ config.ReadOnlyGroup, AppRoles.ReadOnly },
|
|
||||||
{ config.WriteOperateGroup, AppRoles.WriteOperate },
|
|
||||||
{ config.WriteTuneGroup, AppRoles.WriteTune },
|
|
||||||
{ config.WriteConfigureGroup, AppRoles.WriteConfigure },
|
|
||||||
{ config.AlarmAckGroup, AppRoles.AlarmAck }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public IReadOnlyList<string> GetUserRoles(string username)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using (var connection = CreateConnection())
|
|
||||||
{
|
|
||||||
// Bind with service account to search
|
|
||||||
connection.Bind(new NetworkCredential(_config.ServiceAccountDn, _config.ServiceAccountPassword));
|
|
||||||
|
|
||||||
var request = new SearchRequest(
|
|
||||||
_config.BaseDN,
|
|
||||||
$"(cn={EscapeLdapFilter(username)})",
|
|
||||||
SearchScope.Subtree,
|
|
||||||
"memberOf");
|
|
||||||
|
|
||||||
var response = (SearchResponse)connection.SendRequest(request);
|
|
||||||
|
|
||||||
if (response.Entries.Count == 0)
|
|
||||||
{
|
|
||||||
Log.Warning("LDAP search returned no entries for {Username}", username);
|
|
||||||
return new[] { AppRoles.ReadOnly }; // safe fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
var entry = response.Entries[0];
|
|
||||||
var memberOf = entry.Attributes["memberOf"];
|
|
||||||
if (memberOf == null || memberOf.Count == 0)
|
|
||||||
{
|
|
||||||
Log.Debug("No memberOf attributes for {Username}, defaulting to ReadOnly", username);
|
|
||||||
return new[] { AppRoles.ReadOnly };
|
|
||||||
}
|
|
||||||
|
|
||||||
var roles = new List<string>();
|
|
||||||
for (var i = 0; i < memberOf.Count; i++)
|
|
||||||
{
|
|
||||||
var dn = memberOf[i]?.ToString() ?? "";
|
|
||||||
// Extract the OU/CN from the memberOf DN (e.g., "ou=ReadWrite,ou=groups,dc=...")
|
|
||||||
var groupName = ExtractGroupName(dn);
|
|
||||||
if (groupName != null && _groupToRole.TryGetValue(groupName, out var role)) roles.Add(role);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (roles.Count == 0)
|
|
||||||
{
|
|
||||||
Log.Debug("No matching role groups for {Username}, defaulting to ReadOnly", username);
|
|
||||||
roles.Add(AppRoles.ReadOnly);
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Debug("LDAP roles for {Username}: [{Roles}]", username, string.Join(", ", roles));
|
|
||||||
return roles;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Failed to resolve LDAP roles for {Username}, defaulting to ReadOnly", username);
|
|
||||||
return new[] { AppRoles.ReadOnly };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool ValidateCredentials(string username, string password)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var bindDn = _config.BindDnTemplate.Replace("{username}", username);
|
|
||||||
using (var connection = CreateConnection())
|
|
||||||
{
|
|
||||||
connection.Bind(new NetworkCredential(bindDn, password));
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Debug("LDAP bind succeeded for {Username}", username);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (LdapException ex)
|
|
||||||
{
|
|
||||||
Log.Debug("LDAP bind failed for {Username}: {Error}", username, ex.Message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "LDAP error during credential validation for {Username}", username);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private LdapConnection CreateConnection()
|
|
||||||
{
|
|
||||||
var identifier = new LdapDirectoryIdentifier(_config.Host, _config.Port);
|
|
||||||
var connection = new LdapConnection(identifier)
|
|
||||||
{
|
|
||||||
AuthType = AuthType.Basic,
|
|
||||||
Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds)
|
|
||||||
};
|
|
||||||
connection.SessionOptions.ProtocolVersion = 3;
|
|
||||||
return connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? ExtractGroupName(string dn)
|
|
||||||
{
|
|
||||||
// Parse "ou=ReadWrite,ou=groups,dc=..." or "cn=ReadWrite,..."
|
|
||||||
if (string.IsNullOrEmpty(dn)) return null;
|
|
||||||
var parts = dn.Split(',');
|
|
||||||
if (parts.Length == 0) return null;
|
|
||||||
var first = parts[0].Trim();
|
|
||||||
var eqIdx = first.IndexOf('=');
|
|
||||||
return eqIdx >= 0 ? first.Substring(eqIdx + 1) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string EscapeLdapFilter(string input)
|
|
||||||
{
|
|
||||||
return input
|
|
||||||
.Replace("\\", "\\5c")
|
|
||||||
.Replace("*", "\\2a")
|
|
||||||
.Replace("(", "\\28")
|
|
||||||
.Replace(")", "\\29")
|
|
||||||
.Replace("\0", "\\00");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Stable identifiers for custom OPC UA roles mapped from LDAP groups.
|
|
||||||
/// The namespace URI is registered in the server namespace table at startup,
|
|
||||||
/// and the string identifiers are resolved to runtime NodeIds before use.
|
|
||||||
/// </summary>
|
|
||||||
public static class LmxRoleIds
|
|
||||||
{
|
|
||||||
public const string NamespaceUri = "urn:zbmom:lmxopcua:roles";
|
|
||||||
|
|
||||||
public const string ReadOnly = "Role.ReadOnly";
|
|
||||||
public const string WriteOperate = "Role.WriteOperate";
|
|
||||||
public const string WriteTune = "Role.WriteTune";
|
|
||||||
public const string WriteConfigure = "Role.WriteConfigure";
|
|
||||||
public const string AlarmAck = "Role.AlarmAck";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Maps Galaxy mx_data_type integers to OPC UA data types and CLR types. (OPC-005)
|
|
||||||
/// See gr/data_type_mapping.md for full mapping table.
|
|
||||||
/// </summary>
|
|
||||||
public static class MxDataTypeMapper
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Maps mx_data_type to OPC UA DataType NodeId numeric identifier.
|
|
||||||
/// Unknown types default to String (i=12).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="mxDataType">The Galaxy MX data type code.</param>
|
|
||||||
/// <returns>The OPC UA built-in data type node identifier.</returns>
|
|
||||||
public static uint MapToOpcUaDataType(int mxDataType)
|
|
||||||
{
|
|
||||||
return mxDataType switch
|
|
||||||
{
|
|
||||||
1 => 1, // Boolean → i=1
|
|
||||||
2 => 6, // Integer → Int32 i=6
|
|
||||||
3 => 10, // Float → Float i=10
|
|
||||||
4 => 11, // Double → Double i=11
|
|
||||||
5 => 12, // String → String i=12
|
|
||||||
6 => 13, // Time → DateTime i=13
|
|
||||||
7 => 11, // ElapsedTime → Double i=11 (seconds)
|
|
||||||
8 => 12, // Reference → String i=12
|
|
||||||
13 => 6, // Enumeration → Int32 i=6
|
|
||||||
14 => 12, // Custom → String i=12
|
|
||||||
15 => 21, // InternationalizedString → LocalizedText i=21
|
|
||||||
16 => 12, // Custom → String i=12
|
|
||||||
_ => 12 // Unknown → String i=12
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maps mx_data_type to the corresponding CLR type.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="mxDataType">The Galaxy MX data type code.</param>
|
|
||||||
/// <returns>The CLR type used to represent runtime values for the MX type.</returns>
|
|
||||||
public static Type MapToClrType(int mxDataType)
|
|
||||||
{
|
|
||||||
return mxDataType switch
|
|
||||||
{
|
|
||||||
1 => typeof(bool),
|
|
||||||
2 => typeof(int),
|
|
||||||
3 => typeof(float),
|
|
||||||
4 => typeof(double),
|
|
||||||
5 => typeof(string),
|
|
||||||
6 => typeof(DateTime),
|
|
||||||
7 => typeof(double), // ElapsedTime as seconds
|
|
||||||
8 => typeof(string), // Reference as string
|
|
||||||
13 => typeof(int), // Enum backing integer
|
|
||||||
14 => typeof(string),
|
|
||||||
15 => typeof(string), // LocalizedText stored as string
|
|
||||||
16 => typeof(string),
|
|
||||||
_ => typeof(string)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the OPC UA type name for a given mx_data_type.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="mxDataType">The Galaxy MX data type code.</param>
|
|
||||||
/// <returns>The OPC UA type name used in diagnostics.</returns>
|
|
||||||
public static string GetOpcUaTypeName(int mxDataType)
|
|
||||||
{
|
|
||||||
return mxDataType switch
|
|
||||||
{
|
|
||||||
1 => "Boolean",
|
|
||||||
2 => "Int32",
|
|
||||||
3 => "Float",
|
|
||||||
4 => "Double",
|
|
||||||
5 => "String",
|
|
||||||
6 => "DateTime",
|
|
||||||
7 => "Double",
|
|
||||||
8 => "String",
|
|
||||||
13 => "Int32",
|
|
||||||
14 => "String",
|
|
||||||
15 => "LocalizedText",
|
|
||||||
16 => "String",
|
|
||||||
_ => "String"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Translates MXAccess error codes (1008, 1012, 1013, etc.) to human-readable messages. (MXA-009)
|
|
||||||
/// </summary>
|
|
||||||
public static class MxErrorCodes
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The requested Galaxy attribute reference does not resolve in the runtime.
|
|
||||||
/// </summary>
|
|
||||||
public const int MX_E_InvalidReference = 1008;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The supplied value does not match the attribute's configured data type.
|
|
||||||
/// </summary>
|
|
||||||
public const int MX_E_WrongDataType = 1012;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The target attribute cannot be written because it is read-only or protected.
|
|
||||||
/// </summary>
|
|
||||||
public const int MX_E_NotWritable = 1013;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The runtime did not complete the operation within the configured timeout.
|
|
||||||
/// </summary>
|
|
||||||
public const int MX_E_RequestTimedOut = 1014;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Communication with the MXAccess runtime failed during the operation.
|
|
||||||
/// </summary>
|
|
||||||
public const int MX_E_CommFailure = 1015;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The operation was attempted without an active MXAccess session.
|
|
||||||
/// </summary>
|
|
||||||
public const int MX_E_NotConnected = 1016;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Converts a numeric MXAccess error code into an operator-facing message.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="errorCode">The MXAccess error code returned by the runtime.</param>
|
|
||||||
/// <returns>A human-readable description of the runtime failure.</returns>
|
|
||||||
public static string GetMessage(int errorCode)
|
|
||||||
{
|
|
||||||
return errorCode switch
|
|
||||||
{
|
|
||||||
1008 => "Invalid reference: the tag address does not exist or is malformed",
|
|
||||||
1012 => "Wrong data type: the value type does not match the attribute's expected type",
|
|
||||||
1013 => "Not writable: the attribute is read-only or locked",
|
|
||||||
1014 => "Request timed out: the operation did not complete within the allowed time",
|
|
||||||
1015 => "Communication failure: lost connection to the runtime",
|
|
||||||
1016 => "Not connected: no active connection to the Galaxy runtime",
|
|
||||||
_ => $"Unknown MXAccess error code: {errorCode}"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maps an MXAccess error code to the OPC quality state that should be exposed to clients.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="errorCode">The MXAccess error code returned by the runtime.</param>
|
|
||||||
/// <returns>The quality classification that best represents the runtime failure.</returns>
|
|
||||||
public static Quality MapToQuality(int errorCode)
|
|
||||||
{
|
|
||||||
return errorCode switch
|
|
||||||
{
|
|
||||||
1008 => Quality.BadConfigError,
|
|
||||||
1012 => Quality.BadConfigError,
|
|
||||||
1013 => Quality.BadOutOfService,
|
|
||||||
1014 => Quality.BadCommFailure,
|
|
||||||
1015 => Quality.BadCommFailure,
|
|
||||||
1016 => Quality.BadNotConnected,
|
|
||||||
_ => Quality.Bad
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Maps a deployed Galaxy platform to the hostname where it executes.
|
|
||||||
/// </summary>
|
|
||||||
public class PlatformInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the gobject_id of the platform object in the Galaxy repository.
|
|
||||||
/// </summary>
|
|
||||||
public int GobjectId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the hostname (node_name) where the platform is deployed.
|
|
||||||
/// </summary>
|
|
||||||
public string NodeName { get; set; } = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// OPC DA quality codes mapped from MXAccess quality values. (MXA-009, OPC-005)
|
|
||||||
/// </summary>
|
|
||||||
public enum Quality : byte
|
|
||||||
{
|
|
||||||
// Bad family (0-63)
|
|
||||||
/// <summary>
|
|
||||||
/// No valid process value is available.
|
|
||||||
/// </summary>
|
|
||||||
Bad = 0,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The value is invalid because the Galaxy attribute definition or mapping is wrong.
|
|
||||||
/// </summary>
|
|
||||||
BadConfigError = 4,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The bridge is not currently connected to the Galaxy runtime.
|
|
||||||
/// </summary>
|
|
||||||
BadNotConnected = 8,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The runtime device or adapter failed while obtaining the value.
|
|
||||||
/// </summary>
|
|
||||||
BadDeviceFailure = 12,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The underlying field source reported a bad sensor condition.
|
|
||||||
/// </summary>
|
|
||||||
BadSensorFailure = 16,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Communication with the runtime failed while retrieving the value.
|
|
||||||
/// </summary>
|
|
||||||
BadCommFailure = 20,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The attribute is intentionally unavailable for service, such as a locked or unwritable value.
|
|
||||||
/// </summary>
|
|
||||||
BadOutOfService = 24,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The bridge is still waiting for the first usable value after startup or resubscription.
|
|
||||||
/// </summary>
|
|
||||||
BadWaitingForInitialData = 32,
|
|
||||||
|
|
||||||
// Uncertain family (64-191)
|
|
||||||
/// <summary>
|
|
||||||
/// A value is available, but it should be treated cautiously.
|
|
||||||
/// </summary>
|
|
||||||
Uncertain = 64,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The last usable value is being repeated because a newer one is unavailable.
|
|
||||||
/// </summary>
|
|
||||||
UncertainLastUsable = 68,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The sensor or source is providing a value with reduced accuracy.
|
|
||||||
/// </summary>
|
|
||||||
UncertainSensorNotAccurate = 80,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The value exceeds its engineered limits.
|
|
||||||
/// </summary>
|
|
||||||
UncertainEuExceeded = 84,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The source is operating in a degraded or subnormal state.
|
|
||||||
/// </summary>
|
|
||||||
UncertainSubNormal = 88,
|
|
||||||
|
|
||||||
// Good family (192+)
|
|
||||||
/// <summary>
|
|
||||||
/// The value is current and suitable for normal client use.
|
|
||||||
/// </summary>
|
|
||||||
Good = 192,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The value is good but currently overridden locally rather than flowing from the live source.
|
|
||||||
/// </summary>
|
|
||||||
GoodLocalOverride = 216
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Helper methods for reasoning about OPC quality families used by the bridge.
|
|
||||||
/// </summary>
|
|
||||||
public static class QualityExtensions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Determines whether the quality represents a good runtime value that can be trusted by OPC UA clients.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="q">The quality code to inspect.</param>
|
|
||||||
/// <returns><see langword="true" /> when the value is in the good quality range; otherwise, <see langword="false" />.</returns>
|
|
||||||
public static bool IsGood(this Quality q)
|
|
||||||
{
|
|
||||||
return (byte)q >= 192;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Determines whether the quality represents an uncertain runtime value that should be treated cautiously.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="q">The quality code to inspect.</param>
|
|
||||||
/// <returns><see langword="true" /> when the value is in the uncertain range; otherwise, <see langword="false" />.</returns>
|
|
||||||
public static bool IsUncertain(this Quality q)
|
|
||||||
{
|
|
||||||
return (byte)q >= 64 && (byte)q < 192;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Determines whether the quality represents a bad runtime value that should not be used as valid process data.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="q">The quality code to inspect.</param>
|
|
||||||
/// <returns><see langword="true" /> when the value is in the bad range; otherwise, <see langword="false" />.</returns>
|
|
||||||
public static bool IsBad(this Quality q)
|
|
||||||
{
|
|
||||||
return (byte)q < 64;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Maps MXAccess integer quality to domain Quality enum and OPC UA StatusCodes. (MXA-009, OPC-005)
|
|
||||||
/// </summary>
|
|
||||||
public static class QualityMapper
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Maps an MXAccess quality integer (OPC DA quality byte) to domain Quality.
|
|
||||||
/// Uses category bits: 192+ = Good, 64-191 = Uncertain, 0-63 = Bad.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="mxQuality">The raw MXAccess quality integer.</param>
|
|
||||||
/// <returns>The mapped bridge quality value.</returns>
|
|
||||||
public static Quality MapFromMxAccessQuality(int mxQuality)
|
|
||||||
{
|
|
||||||
var b = (byte)(mxQuality & 0xFF);
|
|
||||||
|
|
||||||
// Try exact match first
|
|
||||||
if (Enum.IsDefined(typeof(Quality), b))
|
|
||||||
return (Quality)b;
|
|
||||||
|
|
||||||
// Fall back to category
|
|
||||||
if (b >= 192) return Quality.Good;
|
|
||||||
if (b >= 64) return Quality.Uncertain;
|
|
||||||
return Quality.Bad;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maps domain Quality to OPC UA StatusCode uint32.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quality">The bridge quality value.</param>
|
|
||||||
/// <returns>The OPC UA status code represented as a 32-bit unsigned integer.</returns>
|
|
||||||
public static uint MapToOpcUaStatusCode(Quality quality)
|
|
||||||
{
|
|
||||||
return quality switch
|
|
||||||
{
|
|
||||||
Quality.Good => 0x00000000u, // Good
|
|
||||||
Quality.GoodLocalOverride => 0x00D80000u, // Good_LocalOverride
|
|
||||||
Quality.Uncertain => 0x40000000u, // Uncertain
|
|
||||||
Quality.UncertainLastUsable => 0x40900000u,
|
|
||||||
Quality.UncertainSensorNotAccurate => 0x40930000u,
|
|
||||||
Quality.UncertainEuExceeded => 0x40940000u,
|
|
||||||
Quality.UncertainSubNormal => 0x40950000u,
|
|
||||||
Quality.Bad => 0x80000000u, // Bad
|
|
||||||
Quality.BadConfigError => 0x80890000u,
|
|
||||||
Quality.BadNotConnected => 0x808A0000u,
|
|
||||||
Quality.BadDeviceFailure => 0x808B0000u,
|
|
||||||
Quality.BadSensorFailure => 0x808C0000u,
|
|
||||||
Quality.BadCommFailure => 0x80050000u,
|
|
||||||
Quality.BadOutOfService => 0x808D0000u,
|
|
||||||
Quality.BadWaitingForInitialData => 0x80320000u,
|
|
||||||
_ => quality.IsGood() ? 0x00000000u :
|
|
||||||
quality.IsUncertain() ? 0x40000000u :
|
|
||||||
0x80000000u
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Maps Galaxy security classification values to OPC UA write access decisions.
|
|
||||||
/// See gr/data_type_mapping.md for the full mapping table.
|
|
||||||
/// </summary>
|
|
||||||
public static class SecurityClassificationMapper
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Determines whether an attribute with the given security classification should allow writes.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="securityClassification">The Galaxy security classification value.</param>
|
|
||||||
/// <returns>
|
|
||||||
/// <see langword="true" /> for FreeAccess (0), Operate (1), Tune (4), Configure (5);
|
|
||||||
/// <see langword="false" /> for SecuredWrite (2), VerifiedWrite (3), ViewOnly (6).
|
|
||||||
/// </returns>
|
|
||||||
public static bool IsWritable(int securityClassification)
|
|
||||||
{
|
|
||||||
switch (securityClassification)
|
|
||||||
{
|
|
||||||
case 2: // SecuredWrite
|
|
||||||
case 3: // VerifiedWrite
|
|
||||||
case 6: // ViewOnly
|
|
||||||
return false;
|
|
||||||
default:
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Domain
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Value-Timestamp-Quality triplet for tag data. (MXA-003, OPC-007)
|
|
||||||
/// </summary>
|
|
||||||
public readonly struct Vtq : IEquatable<Vtq>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the runtime value returned for the Galaxy attribute.
|
|
||||||
/// </summary>
|
|
||||||
public object? Value { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the timestamp associated with the runtime value.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime Timestamp { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the quality classification that tells OPC UA clients whether the value is usable.
|
|
||||||
/// </summary>
|
|
||||||
public Quality Quality { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="Vtq" /> struct for a Galaxy attribute value.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="value">The runtime value returned by MXAccess.</param>
|
|
||||||
/// <param name="timestamp">The timestamp assigned to the runtime value.</param>
|
|
||||||
/// <param name="quality">The quality classification for the runtime value.</param>
|
|
||||||
public Vtq(object? value, DateTime timestamp, Quality quality)
|
|
||||||
{
|
|
||||||
Value = value;
|
|
||||||
Timestamp = timestamp;
|
|
||||||
Quality = quality;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a good-quality VTQ snapshot for a successfully read or subscribed attribute value.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="value">The runtime value to wrap.</param>
|
|
||||||
/// <returns>A VTQ carrying the provided value with the current UTC timestamp and good quality.</returns>
|
|
||||||
public static Vtq Good(object? value)
|
|
||||||
{
|
|
||||||
return new Vtq(value, DateTime.UtcNow, Quality.Good);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a bad-quality VTQ snapshot when no usable runtime value is available.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quality">The specific bad quality reason to expose to clients.</param>
|
|
||||||
/// <returns>A VTQ with no value, the current UTC timestamp, and the requested bad quality.</returns>
|
|
||||||
public static Vtq Bad(Quality quality = Quality.Bad)
|
|
||||||
{
|
|
||||||
return new Vtq(null, DateTime.UtcNow, quality);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates an uncertain VTQ snapshot when the runtime value exists but should be treated cautiously.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="value">The runtime value to wrap.</param>
|
|
||||||
/// <returns>A VTQ carrying the provided value with the current UTC timestamp and uncertain quality.</returns>
|
|
||||||
public static Vtq Uncertain(object? value)
|
|
||||||
{
|
|
||||||
return new Vtq(value, DateTime.UtcNow, Quality.Uncertain);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Compares two VTQ snapshots for exact value, timestamp, and quality equality.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="other">The other VTQ snapshot to compare.</param>
|
|
||||||
/// <returns><see langword="true" /> when all fields match; otherwise, <see langword="false" />.</returns>
|
|
||||||
public bool Equals(Vtq other)
|
|
||||||
{
|
|
||||||
return Equals(Value, other.Value) && Timestamp == other.Timestamp && Quality == other.Quality;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override bool Equals(object? obj)
|
|
||||||
{
|
|
||||||
return obj is Vtq other && Equals(other);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override int GetHashCode()
|
|
||||||
{
|
|
||||||
return HashCode.Combine(Value, Timestamp, Quality);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override string ToString()
|
|
||||||
{
|
|
||||||
return $"Vtq({Value}, {Timestamp:O}, {Quality})";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
|
||||||
<Costura>
|
|
||||||
<ExcludeAssemblies>
|
|
||||||
ArchestrA.MxAccess
|
|
||||||
</ExcludeAssemblies>
|
|
||||||
</Costura>
|
|
||||||
</Weavers>
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
|
||||||
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
|
|
||||||
<xs:element name="Weavers">
|
|
||||||
<xs:complexType>
|
|
||||||
<xs:all>
|
|
||||||
<xs:element name="Costura" minOccurs="0" maxOccurs="1">
|
|
||||||
<xs:complexType>
|
|
||||||
<xs:all>
|
|
||||||
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeAssemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:element>
|
|
||||||
<xs:element minOccurs="0" maxOccurs="1" name="IncludeAssemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:element>
|
|
||||||
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeRuntimeAssemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:element>
|
|
||||||
<xs:element minOccurs="0" maxOccurs="1" name="IncludeRuntimeAssemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:element>
|
|
||||||
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged32Assemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>Obsolete, use UnmanagedWinX86Assemblies instead</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:element>
|
|
||||||
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinX86Assemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>A list of unmanaged X86 (32 bit) assembly names to include, delimited with line breaks.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:element>
|
|
||||||
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged64Assemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>Obsolete, use UnmanagedWinX64Assemblies instead.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:element>
|
|
||||||
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinX64Assemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>A list of unmanaged X64 (64 bit) assembly names to include, delimited with line breaks.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:element>
|
|
||||||
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinArm64Assemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with line breaks.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:element>
|
|
||||||
<xs:element minOccurs="0" maxOccurs="1" name="PreloadOrder" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>The order of preloaded assemblies, delimited with line breaks.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:element>
|
|
||||||
</xs:all>
|
|
||||||
<xs:attribute name="CreateTemporaryAssemblies" type="xs:boolean">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>This will copy embedded files to disk before loading them into memory. This is helpful for some scenarios that expected an assembly to be loaded from a physical file.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="IncludeDebugSymbols" type="xs:boolean">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>Controls if .pdbs for reference assemblies are also embedded.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="IncludeRuntimeReferences" type="xs:boolean">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>Controls if runtime assemblies are also embedded.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="UseRuntimeReferencePaths" type="xs:boolean">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>Controls whether the runtime assemblies are embedded with their full path or only with their assembly name.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="DisableCompression" type="xs:boolean">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="DisableCleanup" type="xs:boolean">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="DisableEventSubscription" type="xs:boolean">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>The attach method no longer subscribes to the `AppDomain.AssemblyResolve` (.NET 4.x) and `AssemblyLoadContext.Resolving` (.NET 6.0+) events.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="LoadAtModuleInit" type="xs:boolean">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>Costura by default will load as part of the module initialization. This flag disables that behavior. Make sure you call CosturaUtility.Initialize() somewhere in your code.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="IgnoreSatelliteAssemblies" type="xs:boolean">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>Costura will by default use assemblies with a name like 'resources.dll' as a satellite resource and prepend the output path. This flag disables that behavior.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="ExcludeAssemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="IncludeAssemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="ExcludeRuntimeAssemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="IncludeRuntimeAssemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="Unmanaged32Assemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>Obsolete, use UnmanagedWinX86Assemblies instead</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="UnmanagedWinX86Assemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>A list of unmanaged X86 (32 bit) assembly names to include, delimited with |.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="Unmanaged64Assemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>Obsolete, use UnmanagedWinX64Assemblies instead</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="UnmanagedWinX64Assemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>A list of unmanaged X64 (64 bit) assembly names to include, delimited with |.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="UnmanagedWinArm64Assemblies" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with |.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="PreloadOrder" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>The order of preloaded assemblies, delimited with |.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
</xs:complexType>
|
|
||||||
</xs:element>
|
|
||||||
</xs:all>
|
|
||||||
<xs:attribute name="VerifyAssembly" type="xs:boolean">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
<xs:attribute name="GenerateXsd" type="xs:boolean">
|
|
||||||
<xs:annotation>
|
|
||||||
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
|
|
||||||
</xs:annotation>
|
|
||||||
</xs:attribute>
|
|
||||||
</xs:complexType>
|
|
||||||
</xs:element>
|
|
||||||
</xs:schema>
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Polls the Galaxy database for deployment changes and fires OnGalaxyChanged. (GR-003, GR-004)
|
|
||||||
/// </summary>
|
|
||||||
public class ChangeDetectionService : IDisposable
|
|
||||||
{
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<ChangeDetectionService>();
|
|
||||||
private readonly int _intervalSeconds;
|
|
||||||
|
|
||||||
private readonly IGalaxyRepository _repository;
|
|
||||||
private CancellationTokenSource? _cts;
|
|
||||||
private Task? _pollTask;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new change detector for Galaxy deploy timestamps.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="repository">The repository used to query the latest deploy timestamp.</param>
|
|
||||||
/// <param name="intervalSeconds">The polling interval, in seconds, between deploy checks.</param>
|
|
||||||
/// <param name="initialDeployTime">An optional deploy timestamp already known at service startup.</param>
|
|
||||||
public ChangeDetectionService(IGalaxyRepository repository, int intervalSeconds,
|
|
||||||
DateTime? initialDeployTime = null)
|
|
||||||
{
|
|
||||||
_repository = repository;
|
|
||||||
_intervalSeconds = intervalSeconds;
|
|
||||||
LastKnownDeployTime = initialDeployTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the last deploy timestamp observed by the polling loop.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? LastKnownDeployTime { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops the polling loop and disposes the underlying cancellation resources.
|
|
||||||
/// </summary>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
Stop();
|
|
||||||
_cts?.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when a new Galaxy deploy timestamp indicates the OPC UA address space should be rebuilt.
|
|
||||||
/// </summary>
|
|
||||||
public event Action? OnGalaxyChanged;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Starts the background polling loop that watches for Galaxy deploy changes.
|
|
||||||
/// </summary>
|
|
||||||
public void Start()
|
|
||||||
{
|
|
||||||
if (_cts != null)
|
|
||||||
Stop();
|
|
||||||
|
|
||||||
_cts = new CancellationTokenSource();
|
|
||||||
_pollTask = Task.Run(() => PollLoopAsync(_cts.Token));
|
|
||||||
Log.Information("Change detection started (interval={Interval}s)", _intervalSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops the background polling loop.
|
|
||||||
/// </summary>
|
|
||||||
public void Stop()
|
|
||||||
{
|
|
||||||
_cts?.Cancel();
|
|
||||||
try { _pollTask?.Wait(TimeSpan.FromSeconds(5)); } catch { /* timeout or faulted */ }
|
|
||||||
_pollTask = null;
|
|
||||||
Log.Information("Change detection stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task PollLoopAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
// If no initial deploy time was provided, first poll triggers unconditionally
|
|
||||||
var firstPoll = LastKnownDeployTime == null;
|
|
||||||
|
|
||||||
while (!ct.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var deployTime = await _repository.GetLastDeployTimeAsync(ct);
|
|
||||||
|
|
||||||
if (firstPoll)
|
|
||||||
{
|
|
||||||
firstPoll = false;
|
|
||||||
LastKnownDeployTime = deployTime;
|
|
||||||
Log.Information("Initial deploy time: {DeployTime}", deployTime);
|
|
||||||
OnGalaxyChanged?.Invoke();
|
|
||||||
}
|
|
||||||
else if (deployTime != LastKnownDeployTime)
|
|
||||||
{
|
|
||||||
Log.Information("Galaxy deployment change detected: {Previous} → {Current}",
|
|
||||||
LastKnownDeployTime, deployTime);
|
|
||||||
LastKnownDeployTime = deployTime;
|
|
||||||
OnGalaxyChanged?.Invoke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Change detection poll failed, will retry next interval");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(_intervalSeconds), ct);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Data.SqlClient;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Implements IGalaxyRepository using SQL queries against the Galaxy ZB database. (GR-001 through GR-007)
|
|
||||||
/// </summary>
|
|
||||||
public class GalaxyRepositoryService : IGalaxyRepository
|
|
||||||
{
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<GalaxyRepositoryService>();
|
|
||||||
|
|
||||||
private readonly GalaxyRepositoryConfiguration _config;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// When <see cref="Configuration.GalaxyScope.LocalPlatform" /> filtering is active, caches the set of
|
|
||||||
/// gobject_ids that passed the hierarchy filter so <see cref="GetAttributesAsync" /> can apply the same scope.
|
|
||||||
/// Populated by <see cref="GetHierarchyAsync" /> and consumed by <see cref="GetAttributesAsync" />.
|
|
||||||
/// </summary>
|
|
||||||
private HashSet<int>? _scopeFilteredGobjectIds;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new repository service that reads Galaxy metadata from the configured SQL database.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="config">The repository connection, timeout, and attribute-selection settings.</param>
|
|
||||||
public GalaxyRepositoryService(GalaxyRepositoryConfiguration config)
|
|
||||||
{
|
|
||||||
_config = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when the repository detects a Galaxy deploy change that should trigger an address-space rebuild.
|
|
||||||
/// </summary>
|
|
||||||
public event Action? OnGalaxyChanged;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Queries the Galaxy repository for the deployed object hierarchy that becomes the OPC UA browse tree.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A token that cancels the database query.</param>
|
|
||||||
/// <returns>The deployed Galaxy objects that should appear in the namespace.</returns>
|
|
||||||
public async Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var results = new List<GalaxyObjectInfo>();
|
|
||||||
|
|
||||||
using var conn = new SqlConnection(_config.ConnectionString);
|
|
||||||
await conn.OpenAsync(ct);
|
|
||||||
|
|
||||||
using var cmd = new SqlCommand(HierarchySql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
|
|
||||||
using var reader = await cmd.ExecuteReaderAsync(ct);
|
|
||||||
|
|
||||||
while (await reader.ReadAsync(ct))
|
|
||||||
{
|
|
||||||
var templateChainRaw = reader.IsDBNull(8) ? "" : reader.GetString(8);
|
|
||||||
var templateChain = string.IsNullOrEmpty(templateChainRaw)
|
|
||||||
? new List<string>()
|
|
||||||
: templateChainRaw.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)
|
|
||||||
.Select(s => s.Trim())
|
|
||||||
.Where(s => s.Length > 0)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
results.Add(new GalaxyObjectInfo
|
|
||||||
{
|
|
||||||
GobjectId = Convert.ToInt32(reader.GetValue(0)),
|
|
||||||
TagName = reader.GetString(1),
|
|
||||||
ContainedName = reader.IsDBNull(2) ? "" : reader.GetString(2),
|
|
||||||
BrowseName = reader.GetString(3),
|
|
||||||
ParentGobjectId = Convert.ToInt32(reader.GetValue(4)),
|
|
||||||
IsArea = Convert.ToInt32(reader.GetValue(5)) == 1,
|
|
||||||
CategoryId = Convert.ToInt32(reader.GetValue(6)),
|
|
||||||
HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)),
|
|
||||||
TemplateChain = templateChain
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (results.Count == 0)
|
|
||||||
Log.Warning("GetHierarchyAsync returned zero rows");
|
|
||||||
else
|
|
||||||
Log.Information("GetHierarchyAsync returned {Count} objects", results.Count);
|
|
||||||
|
|
||||||
if (_config.Scope == GalaxyScope.LocalPlatform)
|
|
||||||
{
|
|
||||||
var platforms = await GetPlatformsAsync(ct);
|
|
||||||
var platformName = string.IsNullOrWhiteSpace(_config.PlatformName)
|
|
||||||
? Environment.MachineName
|
|
||||||
: _config.PlatformName;
|
|
||||||
var (filtered, gobjectIds) = PlatformScopeFilter.Filter(results, platforms, platformName);
|
|
||||||
_scopeFilteredGobjectIds = gobjectIds;
|
|
||||||
return filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
_scopeFilteredGobjectIds = null;
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Queries the Galaxy repository for attribute metadata that becomes OPC UA variable nodes.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A token that cancels the database query.</param>
|
|
||||||
/// <returns>The attribute rows required to build runtime tag mappings and variable metadata.</returns>
|
|
||||||
public async Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var results = new List<GalaxyAttributeInfo>();
|
|
||||||
var extended = _config.ExtendedAttributes;
|
|
||||||
var sql = extended ? ExtendedAttributesSql : AttributesSql;
|
|
||||||
|
|
||||||
using var conn = new SqlConnection(_config.ConnectionString);
|
|
||||||
await conn.OpenAsync(ct);
|
|
||||||
|
|
||||||
using var cmd = new SqlCommand(sql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
|
|
||||||
using var reader = await cmd.ExecuteReaderAsync(ct);
|
|
||||||
|
|
||||||
while (await reader.ReadAsync(ct))
|
|
||||||
results.Add(extended ? ReadExtendedAttribute(reader) : ReadStandardAttribute(reader));
|
|
||||||
|
|
||||||
Log.Information("GetAttributesAsync returned {Count} attributes (extended={Extended})", results.Count,
|
|
||||||
extended);
|
|
||||||
|
|
||||||
if (_config.Scope == GalaxyScope.LocalPlatform && _scopeFilteredGobjectIds != null)
|
|
||||||
return PlatformScopeFilter.FilterAttributes(results, _scopeFilteredGobjectIds);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads the latest Galaxy deploy timestamp so change detection can decide whether the address space is stale.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A token that cancels the database query.</param>
|
|
||||||
/// <returns>The most recent deploy timestamp, or <see langword="null" /> when none is available.</returns>
|
|
||||||
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
using var conn = new SqlConnection(_config.ConnectionString);
|
|
||||||
await conn.OpenAsync(ct);
|
|
||||||
|
|
||||||
using var cmd = new SqlCommand(ChangeDetectionSql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
|
|
||||||
var result = await cmd.ExecuteScalarAsync(ct);
|
|
||||||
|
|
||||||
return result is DateTime dt ? dt : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Executes a lightweight query to confirm that the repository database is reachable.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A token that cancels the connectivity check.</param>
|
|
||||||
/// <returns><see langword="true" /> when the query succeeds; otherwise, <see langword="false" />.</returns>
|
|
||||||
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var conn = new SqlConnection(_config.ConnectionString);
|
|
||||||
await conn.OpenAsync(ct);
|
|
||||||
|
|
||||||
using var cmd = new SqlCommand(TestConnectionSql, conn)
|
|
||||||
{ CommandTimeout = _config.CommandTimeoutSeconds };
|
|
||||||
await cmd.ExecuteScalarAsync(ct);
|
|
||||||
|
|
||||||
Log.Information("Galaxy repository database connection successful");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Galaxy repository database connection failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Queries the platform table for deployed platform-to-hostname mappings used by
|
|
||||||
/// <see cref="Configuration.GalaxyScope.LocalPlatform" /> filtering.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<List<PlatformInfo>> GetPlatformsAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var results = new List<PlatformInfo>();
|
|
||||||
|
|
||||||
using var conn = new SqlConnection(_config.ConnectionString);
|
|
||||||
await conn.OpenAsync(ct);
|
|
||||||
|
|
||||||
using var cmd = new SqlCommand(PlatformLookupSql, conn) { CommandTimeout = _config.CommandTimeoutSeconds };
|
|
||||||
using var reader = await cmd.ExecuteReaderAsync(ct);
|
|
||||||
|
|
||||||
while (await reader.ReadAsync(ct))
|
|
||||||
{
|
|
||||||
results.Add(new PlatformInfo
|
|
||||||
{
|
|
||||||
GobjectId = Convert.ToInt32(reader.GetValue(0)),
|
|
||||||
NodeName = reader.IsDBNull(1) ? "" : reader.GetString(1)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Information("GetPlatformsAsync returned {Count} platform(s)", results.Count);
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads a row from the standard attributes query (12 columns).
|
|
||||||
/// Columns: gobject_id, tag_name, attribute_name, full_tag_reference, mx_data_type,
|
|
||||||
/// data_type_name, is_array, array_dimension, mx_attribute_category,
|
|
||||||
/// security_classification, is_historized, is_alarm
|
|
||||||
/// </summary>
|
|
||||||
private static GalaxyAttributeInfo ReadStandardAttribute(SqlDataReader reader)
|
|
||||||
{
|
|
||||||
return new GalaxyAttributeInfo
|
|
||||||
{
|
|
||||||
GobjectId = Convert.ToInt32(reader.GetValue(0)),
|
|
||||||
TagName = reader.GetString(1),
|
|
||||||
AttributeName = reader.GetString(2),
|
|
||||||
FullTagReference = reader.GetString(3),
|
|
||||||
MxDataType = Convert.ToInt32(reader.GetValue(4)),
|
|
||||||
DataTypeName = reader.IsDBNull(5) ? "" : reader.GetString(5),
|
|
||||||
IsArray = Convert.ToBoolean(reader.GetValue(6)),
|
|
||||||
ArrayDimension = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)),
|
|
||||||
SecurityClassification = Convert.ToInt32(reader.GetValue(9)),
|
|
||||||
IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1,
|
|
||||||
IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads a row from the extended attributes query (14 columns).
|
|
||||||
/// Columns: gobject_id, tag_name, primitive_name, attribute_name, full_tag_reference,
|
|
||||||
/// mx_data_type, data_type_name, is_array, array_dimension,
|
|
||||||
/// mx_attribute_category, security_classification, is_historized, is_alarm, attribute_source
|
|
||||||
/// </summary>
|
|
||||||
private static GalaxyAttributeInfo ReadExtendedAttribute(SqlDataReader reader)
|
|
||||||
{
|
|
||||||
return new GalaxyAttributeInfo
|
|
||||||
{
|
|
||||||
GobjectId = Convert.ToInt32(reader.GetValue(0)),
|
|
||||||
TagName = reader.GetString(1),
|
|
||||||
PrimitiveName = reader.IsDBNull(2) ? "" : reader.GetString(2),
|
|
||||||
AttributeName = reader.GetString(3),
|
|
||||||
FullTagReference = reader.GetString(4),
|
|
||||||
MxDataType = Convert.ToInt32(reader.GetValue(5)),
|
|
||||||
DataTypeName = reader.IsDBNull(6) ? "" : reader.GetString(6),
|
|
||||||
IsArray = Convert.ToBoolean(reader.GetValue(7)),
|
|
||||||
ArrayDimension = reader.IsDBNull(8) ? null : Convert.ToInt32(reader.GetValue(8)),
|
|
||||||
SecurityClassification = Convert.ToInt32(reader.GetValue(10)),
|
|
||||||
IsHistorized = Convert.ToInt32(reader.GetValue(11)) == 1,
|
|
||||||
IsAlarm = Convert.ToInt32(reader.GetValue(12)) == 1,
|
|
||||||
AttributeSource = reader.IsDBNull(13) ? "" : reader.GetString(13)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Raises the change event used by tests and monitoring components to simulate or announce a Galaxy deploy.
|
|
||||||
/// </summary>
|
|
||||||
public void RaiseGalaxyChanged()
|
|
||||||
{
|
|
||||||
OnGalaxyChanged?.Invoke();
|
|
||||||
}
|
|
||||||
|
|
||||||
#region SQL Queries (GR-006: const string, no dynamic SQL)
|
|
||||||
|
|
||||||
private const string HierarchySql = @"
|
|
||||||
;WITH template_chain AS (
|
|
||||||
SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
|
|
||||||
t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth
|
|
||||||
FROM gobject g
|
|
||||||
INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id
|
|
||||||
WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0
|
|
||||||
UNION ALL
|
|
||||||
SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, t.derived_from_gobject_id, tc.depth + 1
|
|
||||||
FROM template_chain tc
|
|
||||||
INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id
|
|
||||||
WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10
|
|
||||||
)
|
|
||||||
SELECT DISTINCT
|
|
||||||
g.gobject_id,
|
|
||||||
g.tag_name,
|
|
||||||
g.contained_name,
|
|
||||||
CASE WHEN g.contained_name IS NULL OR g.contained_name = ''
|
|
||||||
THEN g.tag_name
|
|
||||||
ELSE g.contained_name
|
|
||||||
END AS browse_name,
|
|
||||||
CASE WHEN g.contained_by_gobject_id = 0
|
|
||||||
THEN g.area_gobject_id
|
|
||||||
ELSE g.contained_by_gobject_id
|
|
||||||
END AS parent_gobject_id,
|
|
||||||
CASE WHEN td.category_id = 13
|
|
||||||
THEN 1
|
|
||||||
ELSE 0
|
|
||||||
END AS is_area,
|
|
||||||
td.category_id AS category_id,
|
|
||||||
g.hosted_by_gobject_id AS hosted_by_gobject_id,
|
|
||||||
ISNULL(
|
|
||||||
STUFF((
|
|
||||||
SELECT '|' + tc.template_tag_name
|
|
||||||
FROM template_chain tc
|
|
||||||
WHERE tc.instance_gobject_id = g.gobject_id
|
|
||||||
ORDER BY tc.depth
|
|
||||||
FOR XML PATH('')
|
|
||||||
), 1, 1, ''),
|
|
||||||
''
|
|
||||||
) AS template_chain
|
|
||||||
FROM gobject g
|
|
||||||
INNER JOIN template_definition td
|
|
||||||
ON g.template_definition_id = td.template_definition_id
|
|
||||||
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
|
||||||
AND g.is_template = 0
|
|
||||||
AND g.deployed_package_id <> 0
|
|
||||||
ORDER BY parent_gobject_id, g.tag_name";
|
|
||||||
|
|
||||||
private const string AttributesSql = @"
|
|
||||||
;WITH deployed_package_chain AS (
|
|
||||||
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
|
|
||||||
FROM gobject g
|
|
||||||
INNER JOIN package p ON p.package_id = g.deployed_package_id
|
|
||||||
WHERE g.is_template = 0 AND g.deployed_package_id <> 0
|
|
||||||
UNION ALL
|
|
||||||
SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
|
|
||||||
FROM deployed_package_chain dpc
|
|
||||||
INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
|
|
||||||
WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
|
|
||||||
)
|
|
||||||
SELECT gobject_id, tag_name, attribute_name, full_tag_reference,
|
|
||||||
mx_data_type, data_type_name, is_array, array_dimension,
|
|
||||||
mx_attribute_category, security_classification, is_historized, is_alarm
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
dpc.gobject_id,
|
|
||||||
g.tag_name,
|
|
||||||
da.attribute_name,
|
|
||||||
g.tag_name + '.' + da.attribute_name
|
|
||||||
+ CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END
|
|
||||||
AS full_tag_reference,
|
|
||||||
da.mx_data_type,
|
|
||||||
dt.description AS data_type_name,
|
|
||||||
da.is_array,
|
|
||||||
CASE WHEN da.is_array = 1
|
|
||||||
THEN CONVERT(int, CONVERT(varbinary(2),
|
|
||||||
SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
|
|
||||||
ELSE NULL
|
|
||||||
END AS array_dimension,
|
|
||||||
da.mx_attribute_category,
|
|
||||||
da.security_classification,
|
|
||||||
CASE WHEN EXISTS (
|
|
||||||
SELECT 1 FROM deployed_package_chain dpc2
|
|
||||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
|
|
||||||
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
|
|
||||||
WHERE dpc2.gobject_id = dpc.gobject_id
|
|
||||||
) THEN 1 ELSE 0 END AS is_historized,
|
|
||||||
CASE WHEN EXISTS (
|
|
||||||
SELECT 1 FROM deployed_package_chain dpc2
|
|
||||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
|
|
||||||
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
|
|
||||||
WHERE dpc2.gobject_id = dpc.gobject_id
|
|
||||||
) THEN 1 ELSE 0 END AS is_alarm,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY dpc.gobject_id, da.attribute_name
|
|
||||||
ORDER BY dpc.depth
|
|
||||||
) AS rn
|
|
||||||
FROM deployed_package_chain dpc
|
|
||||||
INNER JOIN dynamic_attribute da
|
|
||||||
ON da.package_id = dpc.package_id
|
|
||||||
INNER JOIN gobject g
|
|
||||||
ON g.gobject_id = dpc.gobject_id
|
|
||||||
INNER JOIN template_definition td
|
|
||||||
ON td.template_definition_id = g.template_definition_id
|
|
||||||
LEFT JOIN data_type dt
|
|
||||||
ON dt.mx_data_type = da.mx_data_type
|
|
||||||
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
|
||||||
AND da.attribute_name NOT LIKE '[_]%'
|
|
||||||
AND da.attribute_name NOT LIKE '%.Description'
|
|
||||||
AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
|
|
||||||
) ranked
|
|
||||||
WHERE rn = 1
|
|
||||||
ORDER BY tag_name, attribute_name";
|
|
||||||
|
|
||||||
private const string ExtendedAttributesSql = @"
|
|
||||||
;WITH deployed_package_chain AS (
|
|
||||||
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
|
|
||||||
FROM gobject g
|
|
||||||
INNER JOIN package p ON p.package_id = g.deployed_package_id
|
|
||||||
WHERE g.is_template = 0 AND g.deployed_package_id <> 0
|
|
||||||
UNION ALL
|
|
||||||
SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
|
|
||||||
FROM deployed_package_chain dpc
|
|
||||||
INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
|
|
||||||
WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
|
|
||||||
),
|
|
||||||
ranked_dynamic AS (
|
|
||||||
SELECT
|
|
||||||
dpc.gobject_id,
|
|
||||||
g.tag_name,
|
|
||||||
da.attribute_name,
|
|
||||||
g.tag_name + '.' + da.attribute_name
|
|
||||||
+ CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END
|
|
||||||
AS full_tag_reference,
|
|
||||||
da.mx_data_type,
|
|
||||||
dt.description AS data_type_name,
|
|
||||||
da.is_array,
|
|
||||||
CASE WHEN da.is_array = 1
|
|
||||||
THEN CONVERT(int, CONVERT(varbinary(2),
|
|
||||||
SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
|
|
||||||
ELSE NULL
|
|
||||||
END AS array_dimension,
|
|
||||||
da.mx_attribute_category,
|
|
||||||
da.security_classification,
|
|
||||||
CASE WHEN EXISTS (
|
|
||||||
SELECT 1 FROM deployed_package_chain dpc2
|
|
||||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
|
|
||||||
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
|
|
||||||
WHERE dpc2.gobject_id = dpc.gobject_id
|
|
||||||
) THEN 1 ELSE 0 END AS is_historized,
|
|
||||||
CASE WHEN EXISTS (
|
|
||||||
SELECT 1 FROM deployed_package_chain dpc2
|
|
||||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
|
|
||||||
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
|
|
||||||
WHERE dpc2.gobject_id = dpc.gobject_id
|
|
||||||
) THEN 1 ELSE 0 END AS is_alarm,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY dpc.gobject_id, da.attribute_name
|
|
||||||
ORDER BY dpc.depth
|
|
||||||
) AS rn
|
|
||||||
FROM deployed_package_chain dpc
|
|
||||||
INNER JOIN dynamic_attribute da
|
|
||||||
ON da.package_id = dpc.package_id
|
|
||||||
INNER JOIN gobject g
|
|
||||||
ON g.gobject_id = dpc.gobject_id
|
|
||||||
INNER JOIN template_definition td
|
|
||||||
ON td.template_definition_id = g.template_definition_id
|
|
||||||
LEFT JOIN data_type dt
|
|
||||||
ON dt.mx_data_type = da.mx_data_type
|
|
||||||
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
|
||||||
AND da.attribute_name NOT LIKE '[_]%'
|
|
||||||
AND da.attribute_name NOT LIKE '%.Description'
|
|
||||||
AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
gobject_id,
|
|
||||||
tag_name,
|
|
||||||
primitive_name,
|
|
||||||
attribute_name,
|
|
||||||
full_tag_reference,
|
|
||||||
mx_data_type,
|
|
||||||
data_type_name,
|
|
||||||
is_array,
|
|
||||||
array_dimension,
|
|
||||||
mx_attribute_category,
|
|
||||||
security_classification,
|
|
||||||
is_historized,
|
|
||||||
is_alarm,
|
|
||||||
attribute_source
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
g.gobject_id,
|
|
||||||
g.tag_name,
|
|
||||||
pi.primitive_name,
|
|
||||||
ad.attribute_name,
|
|
||||||
CASE WHEN pi.primitive_name = ''
|
|
||||||
THEN g.tag_name + '.' + ad.attribute_name
|
|
||||||
ELSE g.tag_name + '.' + pi.primitive_name + '.' + ad.attribute_name
|
|
||||||
END + CASE WHEN ad.is_array = 1 THEN '[]' ELSE '' END
|
|
||||||
AS full_tag_reference,
|
|
||||||
ad.mx_data_type,
|
|
||||||
dt.description AS data_type_name,
|
|
||||||
ad.is_array,
|
|
||||||
CASE WHEN ad.is_array = 1
|
|
||||||
THEN CONVERT(int, CONVERT(varbinary(2),
|
|
||||||
SUBSTRING(ad.mx_value, 15, 2) + SUBSTRING(ad.mx_value, 13, 2), 2))
|
|
||||||
ELSE NULL
|
|
||||||
END AS array_dimension,
|
|
||||||
ad.mx_attribute_category,
|
|
||||||
ad.security_classification,
|
|
||||||
CAST(0 AS int) AS is_historized,
|
|
||||||
CAST(0 AS int) AS is_alarm,
|
|
||||||
'primitive' AS attribute_source
|
|
||||||
FROM gobject g
|
|
||||||
INNER JOIN instance i
|
|
||||||
ON i.gobject_id = g.gobject_id
|
|
||||||
INNER JOIN template_definition td
|
|
||||||
ON td.template_definition_id = g.template_definition_id
|
|
||||||
AND td.runtime_clsid <> '{00000000-0000-0000-0000-000000000000}'
|
|
||||||
INNER JOIN package p
|
|
||||||
ON p.package_id = g.deployed_package_id
|
|
||||||
INNER JOIN primitive_instance pi
|
|
||||||
ON pi.package_id = p.package_id
|
|
||||||
AND pi.property_bitmask & 0x10 <> 0x10
|
|
||||||
INNER JOIN attribute_definition ad
|
|
||||||
ON ad.primitive_definition_id = pi.primitive_definition_id
|
|
||||||
AND ad.attribute_name NOT LIKE '[_]%'
|
|
||||||
AND ad.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
|
|
||||||
LEFT JOIN data_type dt
|
|
||||||
ON dt.mx_data_type = ad.mx_data_type
|
|
||||||
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
|
||||||
AND g.is_template = 0
|
|
||||||
AND g.deployed_package_id <> 0
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
gobject_id,
|
|
||||||
tag_name,
|
|
||||||
'' AS primitive_name,
|
|
||||||
attribute_name,
|
|
||||||
full_tag_reference,
|
|
||||||
mx_data_type,
|
|
||||||
data_type_name,
|
|
||||||
is_array,
|
|
||||||
array_dimension,
|
|
||||||
mx_attribute_category,
|
|
||||||
security_classification,
|
|
||||||
is_historized,
|
|
||||||
is_alarm,
|
|
||||||
'dynamic' AS attribute_source
|
|
||||||
FROM ranked_dynamic
|
|
||||||
WHERE rn = 1
|
|
||||||
) all_attributes
|
|
||||||
ORDER BY tag_name, primitive_name, attribute_name";
|
|
||||||
|
|
||||||
private const string PlatformLookupSql = @"
|
|
||||||
SELECT p.platform_gobject_id, p.node_name
|
|
||||||
FROM platform p
|
|
||||||
INNER JOIN gobject g ON g.gobject_id = p.platform_gobject_id
|
|
||||||
WHERE g.is_template = 0 AND g.deployed_package_id <> 0";
|
|
||||||
|
|
||||||
private const string ChangeDetectionSql = "SELECT time_of_last_deploy FROM galaxy";
|
|
||||||
|
|
||||||
private const string TestConnectionSql = "SELECT 1";
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// POCO for dashboard: Galaxy repository status info. (DASH-009)
|
|
||||||
/// </summary>
|
|
||||||
public class GalaxyRepositoryStats
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy name currently being represented by the bridge.
|
|
||||||
/// </summary>
|
|
||||||
public string GalaxyName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the Galaxy repository database is reachable.
|
|
||||||
/// </summary>
|
|
||||||
public bool DbConnected { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the latest deploy timestamp read from the Galaxy repository.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? LastDeployTime { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of Galaxy objects currently published into the OPC UA address space.
|
|
||||||
/// </summary>
|
|
||||||
public int ObjectCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of Galaxy attributes currently published into the OPC UA address space.
|
|
||||||
/// </summary>
|
|
||||||
public int AttributeCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the UTC time when the address space was last rebuilt from repository data.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? LastRebuildTime { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Filters a Galaxy object hierarchy to retain only objects hosted by a specific platform
|
|
||||||
/// and the structural areas needed to keep the browse tree connected.
|
|
||||||
/// </summary>
|
|
||||||
public static class PlatformScopeFilter
|
|
||||||
{
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(PlatformScopeFilter));
|
|
||||||
|
|
||||||
private const int CategoryWinPlatform = 1;
|
|
||||||
private const int CategoryAppEngine = 3;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Filters the hierarchy to objects hosted by the platform whose <c>node_name</c> matches
|
|
||||||
/// <paramref name="platformName" />, plus ancestor areas that keep the tree connected.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="hierarchy">The full Galaxy object hierarchy.</param>
|
|
||||||
/// <param name="platforms">Deployed platform-to-hostname mappings from the <c>platform</c> table.</param>
|
|
||||||
/// <param name="platformName">The target hostname to match (case-insensitive).</param>
|
|
||||||
/// <returns>
|
|
||||||
/// The filtered hierarchy and the set of included gobject_ids (for attribute filtering).
|
|
||||||
/// When no matching platform is found, returns an empty list and empty set.
|
|
||||||
/// </returns>
|
|
||||||
public static (List<GalaxyObjectInfo> Hierarchy, HashSet<int> GobjectIds) Filter(
|
|
||||||
List<GalaxyObjectInfo> hierarchy,
|
|
||||||
List<PlatformInfo> platforms,
|
|
||||||
string platformName)
|
|
||||||
{
|
|
||||||
// Find the platform gobject_id that matches the target hostname.
|
|
||||||
var matchingPlatform = platforms.FirstOrDefault(
|
|
||||||
p => string.Equals(p.NodeName, platformName, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (matchingPlatform == null)
|
|
||||||
{
|
|
||||||
Log.Warning(
|
|
||||||
"Scope filter found no deployed platform matching node name '{PlatformName}'; " +
|
|
||||||
"available platforms: [{Available}]",
|
|
||||||
platformName,
|
|
||||||
string.Join(", ", platforms.Select(p => $"{p.NodeName} (gobject_id={p.GobjectId})")));
|
|
||||||
return (new List<GalaxyObjectInfo>(), new HashSet<int>());
|
|
||||||
}
|
|
||||||
|
|
||||||
var platformGobjectId = matchingPlatform.GobjectId;
|
|
||||||
Log.Information(
|
|
||||||
"Scope filter targeting platform '{PlatformName}' (gobject_id={GobjectId})",
|
|
||||||
platformName, platformGobjectId);
|
|
||||||
|
|
||||||
// Build a lookup for the hierarchy by gobject_id.
|
|
||||||
var byId = hierarchy.ToDictionary(o => o.GobjectId);
|
|
||||||
|
|
||||||
// Step 1: Collect all host gobject_ids under this platform.
|
|
||||||
// Walk outward from the platform to find AppEngines (and any deeper hosting objects).
|
|
||||||
var hostIds = new HashSet<int> { platformGobjectId };
|
|
||||||
bool changed;
|
|
||||||
do
|
|
||||||
{
|
|
||||||
changed = false;
|
|
||||||
foreach (var obj in hierarchy)
|
|
||||||
{
|
|
||||||
if (hostIds.Contains(obj.GobjectId))
|
|
||||||
continue;
|
|
||||||
if (obj.HostedByGobjectId != 0 && hostIds.Contains(obj.HostedByGobjectId)
|
|
||||||
&& (obj.CategoryId == CategoryAppEngine || obj.CategoryId == CategoryWinPlatform))
|
|
||||||
{
|
|
||||||
hostIds.Add(obj.GobjectId);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} while (changed);
|
|
||||||
|
|
||||||
// Step 2: Include all non-area objects hosted by any host in the set, plus the hosts themselves.
|
|
||||||
var includedIds = new HashSet<int>(hostIds);
|
|
||||||
foreach (var obj in hierarchy)
|
|
||||||
{
|
|
||||||
if (includedIds.Contains(obj.GobjectId))
|
|
||||||
continue;
|
|
||||||
if (!obj.IsArea && obj.HostedByGobjectId != 0 && hostIds.Contains(obj.HostedByGobjectId))
|
|
||||||
includedIds.Add(obj.GobjectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Walk ParentGobjectId chains upward to include ancestor areas so the tree stays connected.
|
|
||||||
var toWalk = new Queue<int>(includedIds);
|
|
||||||
while (toWalk.Count > 0)
|
|
||||||
{
|
|
||||||
var id = toWalk.Dequeue();
|
|
||||||
if (!byId.TryGetValue(id, out var obj))
|
|
||||||
continue;
|
|
||||||
var parentId = obj.ParentGobjectId;
|
|
||||||
if (parentId != 0 && byId.ContainsKey(parentId) && includedIds.Add(parentId))
|
|
||||||
toWalk.Enqueue(parentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Return the filtered hierarchy preserving original order.
|
|
||||||
var filtered = hierarchy.Where(o => includedIds.Contains(o.GobjectId)).ToList();
|
|
||||||
|
|
||||||
Log.Information(
|
|
||||||
"Scope filter retained {FilteredCount} of {TotalCount} objects for platform '{PlatformName}'",
|
|
||||||
filtered.Count, hierarchy.Count, platformName);
|
|
||||||
|
|
||||||
return (filtered, includedIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Filters attributes to retain only those belonging to objects in the given set.
|
|
||||||
/// </summary>
|
|
||||||
public static List<GalaxyAttributeInfo> FilterAttributes(
|
|
||||||
List<GalaxyAttributeInfo> attributes,
|
|
||||||
HashSet<int> gobjectIds)
|
|
||||||
{
|
|
||||||
var filtered = attributes.Where(a => gobjectIds.Contains(a.GobjectId)).ToList();
|
|
||||||
Log.Information(
|
|
||||||
"Scope filter retained {FilteredCount} of {TotalCount} attributes",
|
|
||||||
filtered.Count, attributes.Count);
|
|
||||||
return filtered;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using Opc.Ua;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Maps OPC UA aggregate NodeIds to the Wonderware Historian AnalogSummary column names
|
|
||||||
/// consumed by the historian plugin. Kept in Host so HistoryReadProcessed can validate
|
|
||||||
/// aggregate support without requiring the plugin to be loaded.
|
|
||||||
/// </summary>
|
|
||||||
public static class HistorianAggregateMap
|
|
||||||
{
|
|
||||||
public static string? MapAggregateToColumn(NodeId aggregateId)
|
|
||||||
{
|
|
||||||
if (aggregateId == ObjectIds.AggregateFunction_Average)
|
|
||||||
return "Average";
|
|
||||||
if (aggregateId == ObjectIds.AggregateFunction_Minimum)
|
|
||||||
return "Minimum";
|
|
||||||
if (aggregateId == ObjectIds.AggregateFunction_Maximum)
|
|
||||||
return "Maximum";
|
|
||||||
if (aggregateId == ObjectIds.AggregateFunction_Count)
|
|
||||||
return "ValueCount";
|
|
||||||
if (aggregateId == ObjectIds.AggregateFunction_Start)
|
|
||||||
return "First";
|
|
||||||
if (aggregateId == ObjectIds.AggregateFunction_End)
|
|
||||||
return "Last";
|
|
||||||
if (aggregateId == ObjectIds.AggregateFunction_StandardDeviationPopulation)
|
|
||||||
return "StdDev";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Point-in-time state of a single historian cluster node. One entry per configured node is
|
|
||||||
/// surfaced inside <see cref="HistorianHealthSnapshot"/> so the status dashboard can render
|
|
||||||
/// per-node health and operators can see which nodes are in cooldown.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class HistorianClusterNodeState
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the configured node hostname exactly as it appears in
|
|
||||||
/// <c>HistorianConfiguration.ServerNames</c>.
|
|
||||||
/// </summary>
|
|
||||||
public string Name { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the node is currently eligible for new connection
|
|
||||||
/// attempts. <see langword="false"/> means the node is in its post-failure cooldown window
|
|
||||||
/// and the picker is skipping it.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsHealthy { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the UTC timestamp at which the node's cooldown expires, or
|
|
||||||
/// <see langword="null"/> when the node is not in cooldown.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? CooldownUntil { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of times this node has transitioned from healthy to failed
|
|
||||||
/// since startup. Does not decrement on recovery.
|
|
||||||
/// </summary>
|
|
||||||
public int FailureCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the message from the most recent failure, or <see langword="null"/> when
|
|
||||||
/// the node has never failed.
|
|
||||||
/// </summary>
|
|
||||||
public string? LastError { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the UTC timestamp of the most recent failure, or <see langword="null"/>
|
|
||||||
/// when the node has never failed.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? LastFailureTime { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// SDK-free representation of a Historian event record exposed by the historian plugin.
|
|
||||||
/// Prevents ArchestrA types from leaking into the Host assembly.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class HistorianEventDto
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
public string? Source { get; set; }
|
|
||||||
public DateTime EventTime { get; set; }
|
|
||||||
public DateTime ReceivedTime { get; set; }
|
|
||||||
public string? DisplayText { get; set; }
|
|
||||||
public ushort Severity { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Point-in-time runtime health of the historian plugin, surfaced to the status dashboard
|
|
||||||
/// and health check service. Fills the gap between the load-time plugin status
|
|
||||||
/// (<see cref="HistorianPluginLoader.LastOutcome"/>) and actual query behavior so operators
|
|
||||||
/// can detect silent query degradation.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class HistorianHealthSnapshot
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the total number of historian read operations attempted since startup
|
|
||||||
/// across all read paths (raw, aggregate, at-time, events).
|
|
||||||
/// </summary>
|
|
||||||
public long TotalQueries { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the total number of read operations that completed without an exception
|
|
||||||
/// being caught by the plugin's error handler. Includes empty result sets as successes —
|
|
||||||
/// the counter reflects "the SDK call returned" not "the SDK call returned data".
|
|
||||||
/// </summary>
|
|
||||||
public long TotalSuccesses { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the total number of read operations that raised an exception. Each failure
|
|
||||||
/// also resets and closes the underlying SDK connection via the existing reconnect path.
|
|
||||||
/// </summary>
|
|
||||||
public long TotalFailures { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of consecutive failures since the last success. Latches until
|
|
||||||
/// a successful query clears it. The health check service uses this as a degradation signal.
|
|
||||||
/// </summary>
|
|
||||||
public int ConsecutiveFailures { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the UTC timestamp of the last successful read, or <see langword="null"/>
|
|
||||||
/// when no query has succeeded since startup.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? LastSuccessTime { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the UTC timestamp of the last failure, or <see langword="null"/> when no
|
|
||||||
/// query has failed since startup.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? LastFailureTime { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the exception message from the most recent failure. Cleared on the next
|
|
||||||
/// successful query.
|
|
||||||
/// </summary>
|
|
||||||
public string? LastError { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the plugin currently holds an open SDK
|
|
||||||
/// connection for the process (historical values) path.
|
|
||||||
/// </summary>
|
|
||||||
public bool ProcessConnectionOpen { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the plugin currently holds an open SDK
|
|
||||||
/// connection for the event (alarm history) path.
|
|
||||||
/// </summary>
|
|
||||||
public bool EventConnectionOpen { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the node the plugin is currently connected to for the process path,
|
|
||||||
/// or <see langword="null"/> when no connection is open.
|
|
||||||
/// </summary>
|
|
||||||
public string? ActiveProcessNode { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the node the plugin is currently connected to for the event path,
|
|
||||||
/// or <see langword="null"/> when no event connection is open.
|
|
||||||
/// </summary>
|
|
||||||
public string? ActiveEventNode { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the total number of configured historian cluster nodes. A value of 1
|
|
||||||
/// reflects a legacy single-node deployment.
|
|
||||||
/// </summary>
|
|
||||||
public int NodeCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of configured nodes that are currently healthy (not in cooldown).
|
|
||||||
/// </summary>
|
|
||||||
public int HealthyNodeCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the per-node cluster state in configuration order.
|
|
||||||
/// </summary>
|
|
||||||
public List<HistorianClusterNodeState> Nodes { get; set; } = new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Reflection;
|
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Result of the most recent historian plugin load attempt.
|
|
||||||
/// </summary>
|
|
||||||
public enum HistorianPluginStatus
|
|
||||||
{
|
|
||||||
/// <summary>Historian.Enabled is false; TryLoad was not called.</summary>
|
|
||||||
Disabled,
|
|
||||||
/// <summary>Plugin DLL was not present in the Historian/ subfolder.</summary>
|
|
||||||
NotFound,
|
|
||||||
/// <summary>Plugin file exists but could not be loaded or instantiated.</summary>
|
|
||||||
LoadFailed,
|
|
||||||
/// <summary>Plugin loaded and an IHistorianDataSource was constructed.</summary>
|
|
||||||
Loaded
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Structured outcome of a <see cref="HistorianPluginLoader.TryLoad"/> or
|
|
||||||
/// <see cref="HistorianPluginLoader.MarkDisabled"/> call, used by the status dashboard.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class HistorianPluginOutcome
|
|
||||||
{
|
|
||||||
public HistorianPluginOutcome(HistorianPluginStatus status, string pluginPath, string? error)
|
|
||||||
{
|
|
||||||
Status = status;
|
|
||||||
PluginPath = pluginPath;
|
|
||||||
Error = error;
|
|
||||||
}
|
|
||||||
|
|
||||||
public HistorianPluginStatus Status { get; }
|
|
||||||
public string PluginPath { get; }
|
|
||||||
public string? Error { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Loads the Wonderware historian plugin assembly from the Historian/ subfolder next to
|
|
||||||
/// the host executable. Used so the aahClientManaged SDK is not needed on hosts that run
|
|
||||||
/// with Historian.Enabled=false.
|
|
||||||
/// </summary>
|
|
||||||
public static class HistorianPluginLoader
|
|
||||||
{
|
|
||||||
private const string PluginSubfolder = "Historian";
|
|
||||||
private const string PluginAssemblyName = "ZB.MOM.WW.OtOpcUa.Historian.Aveva";
|
|
||||||
private const string PluginEntryType = "ZB.MOM.WW.OtOpcUa.Historian.Aveva.AvevaHistorianPluginEntry";
|
|
||||||
private const string PluginEntryMethod = "Create";
|
|
||||||
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(HistorianPluginLoader));
|
|
||||||
private static readonly object ResolverGate = new object();
|
|
||||||
private static bool _resolverInstalled;
|
|
||||||
private static string? _resolvedProbeDirectory;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the outcome of the most recent load attempt (or <see cref="HistorianPluginStatus.Disabled"/>
|
|
||||||
/// if the loader has never been invoked). The dashboard reads this to distinguish "disabled",
|
|
||||||
/// "plugin missing", and "plugin crashed".
|
|
||||||
/// </summary>
|
|
||||||
public static HistorianPluginOutcome LastOutcome { get; private set; }
|
|
||||||
= new HistorianPluginOutcome(HistorianPluginStatus.Disabled, string.Empty, null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Records that the historian plugin is disabled by configuration. Called by
|
|
||||||
/// <c>OpcUaService</c> when <c>Historian.Enabled=false</c> so the status dashboard can
|
|
||||||
/// report the exact reason history is unavailable.
|
|
||||||
/// </summary>
|
|
||||||
public static void MarkDisabled()
|
|
||||||
{
|
|
||||||
LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.Disabled, string.Empty, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attempts to load the historian plugin and construct an <see cref="IHistorianDataSource"/>.
|
|
||||||
/// Returns null on any failure so the server can continue with history unsupported. The
|
|
||||||
/// specific reason is published on <see cref="LastOutcome"/>.
|
|
||||||
/// </summary>
|
|
||||||
public static IHistorianDataSource? TryLoad(HistorianConfiguration config)
|
|
||||||
{
|
|
||||||
var pluginDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, PluginSubfolder);
|
|
||||||
var pluginPath = Path.Combine(pluginDirectory, PluginAssemblyName + ".dll");
|
|
||||||
|
|
||||||
if (!File.Exists(pluginPath))
|
|
||||||
{
|
|
||||||
Log.Warning(
|
|
||||||
"Historian plugin not found at {PluginPath} — history read operations will return BadHistoryOperationUnsupported",
|
|
||||||
pluginPath);
|
|
||||||
LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.NotFound, pluginPath, null);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
EnsureAssemblyResolverInstalled(pluginDirectory);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var assembly = Assembly.LoadFrom(pluginPath);
|
|
||||||
var entryType = assembly.GetType(PluginEntryType, throwOnError: false);
|
|
||||||
if (entryType == null)
|
|
||||||
{
|
|
||||||
Log.Warning("Historian plugin {PluginPath} does not expose {EntryType}", pluginPath, PluginEntryType);
|
|
||||||
LastOutcome = new HistorianPluginOutcome(
|
|
||||||
HistorianPluginStatus.LoadFailed, pluginPath,
|
|
||||||
$"Plugin assembly does not expose entry type {PluginEntryType}");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var create = entryType.GetMethod(PluginEntryMethod, BindingFlags.Public | BindingFlags.Static);
|
|
||||||
if (create == null)
|
|
||||||
{
|
|
||||||
Log.Warning("Historian plugin entry type {EntryType} missing static {Method}", PluginEntryType, PluginEntryMethod);
|
|
||||||
LastOutcome = new HistorianPluginOutcome(
|
|
||||||
HistorianPluginStatus.LoadFailed, pluginPath,
|
|
||||||
$"Plugin entry type {PluginEntryType} is missing a public static {PluginEntryMethod} method");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = create.Invoke(null, new object[] { config });
|
|
||||||
if (result is IHistorianDataSource dataSource)
|
|
||||||
{
|
|
||||||
Log.Information("Historian plugin loaded from {PluginPath}", pluginPath);
|
|
||||||
LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.Loaded, pluginPath, null);
|
|
||||||
return dataSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Warning("Historian plugin {PluginPath} returned an object that does not implement IHistorianDataSource", pluginPath);
|
|
||||||
LastOutcome = new HistorianPluginOutcome(
|
|
||||||
HistorianPluginStatus.LoadFailed, pluginPath,
|
|
||||||
"Plugin entry method returned an object that does not implement IHistorianDataSource");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Failed to load historian plugin from {PluginPath} — history disabled", pluginPath);
|
|
||||||
LastOutcome = new HistorianPluginOutcome(
|
|
||||||
HistorianPluginStatus.LoadFailed, pluginPath,
|
|
||||||
ex.GetBaseException().Message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void EnsureAssemblyResolverInstalled(string pluginDirectory)
|
|
||||||
{
|
|
||||||
lock (ResolverGate)
|
|
||||||
{
|
|
||||||
_resolvedProbeDirectory = pluginDirectory;
|
|
||||||
if (_resolverInstalled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
AppDomain.CurrentDomain.AssemblyResolve += ResolveFromPluginDirectory;
|
|
||||||
_resolverInstalled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Assembly? ResolveFromPluginDirectory(object? sender, ResolveEventArgs args)
|
|
||||||
{
|
|
||||||
var probeDirectory = _resolvedProbeDirectory;
|
|
||||||
if (string.IsNullOrEmpty(probeDirectory))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var requested = new AssemblyName(args.Name);
|
|
||||||
var candidate = Path.Combine(probeDirectory!, requested.Name + ".dll");
|
|
||||||
if (!File.Exists(candidate))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return Assembly.LoadFrom(candidate);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Debug(ex, "Historian plugin resolver failed to load {Candidate}", candidate);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Opc.Ua;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Manages continuation points for OPC UA HistoryRead requests that return
|
|
||||||
/// more data than the per-request limit allows.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class HistoryContinuationPointManager
|
|
||||||
{
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<HistoryContinuationPointManager>();
|
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<Guid, StoredContinuation> _store = new();
|
|
||||||
private readonly TimeSpan _timeout;
|
|
||||||
|
|
||||||
public HistoryContinuationPointManager() : this(TimeSpan.FromMinutes(5)) { }
|
|
||||||
|
|
||||||
internal HistoryContinuationPointManager(TimeSpan timeout)
|
|
||||||
{
|
|
||||||
_timeout = timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stores remaining data values and returns a continuation point identifier.
|
|
||||||
/// </summary>
|
|
||||||
public byte[] Store(List<DataValue> remaining)
|
|
||||||
{
|
|
||||||
PurgeExpired();
|
|
||||||
var id = Guid.NewGuid();
|
|
||||||
_store[id] = new StoredContinuation(remaining, DateTime.UtcNow);
|
|
||||||
Log.Debug("Stored history continuation point {Id} with {Count} remaining values", id, remaining.Count);
|
|
||||||
return id.ToByteArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves and removes the remaining data values for a continuation point.
|
|
||||||
/// Returns null if the continuation point is invalid or expired.
|
|
||||||
/// </summary>
|
|
||||||
public List<DataValue>? Retrieve(byte[] continuationPoint)
|
|
||||||
{
|
|
||||||
PurgeExpired();
|
|
||||||
if (continuationPoint == null || continuationPoint.Length != 16)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var id = new Guid(continuationPoint);
|
|
||||||
if (!_store.TryRemove(id, out var stored))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if (DateTime.UtcNow - stored.CreatedAt > _timeout)
|
|
||||||
{
|
|
||||||
Log.Debug("History continuation point {Id} expired", id);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return stored.Values;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Releases a continuation point without retrieving its data.
|
|
||||||
/// </summary>
|
|
||||||
public void Release(byte[] continuationPoint)
|
|
||||||
{
|
|
||||||
PurgeExpired();
|
|
||||||
if (continuationPoint == null || continuationPoint.Length != 16)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var id = new Guid(continuationPoint);
|
|
||||||
_store.TryRemove(id, out _);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PurgeExpired()
|
|
||||||
{
|
|
||||||
var cutoff = DateTime.UtcNow - _timeout;
|
|
||||||
foreach (var kvp in _store)
|
|
||||||
{
|
|
||||||
if (kvp.Value.CreatedAt < cutoff)
|
|
||||||
_store.TryRemove(kvp.Key, out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class StoredContinuation
|
|
||||||
{
|
|
||||||
public StoredContinuation(List<DataValue> values, DateTime createdAt)
|
|
||||||
{
|
|
||||||
Values = values;
|
|
||||||
CreatedAt = createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<DataValue> Values { get; }
|
|
||||||
public DateTime CreatedAt { get; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Opc.Ua;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// OPC UA-typed surface for the historian plugin. Host consumers depend only on this
|
|
||||||
/// interface so the Wonderware Historian SDK assemblies are not required unless the
|
|
||||||
/// plugin is loaded at runtime.
|
|
||||||
/// </summary>
|
|
||||||
public interface IHistorianDataSource : IDisposable
|
|
||||||
{
|
|
||||||
Task<List<DataValue>> ReadRawAsync(
|
|
||||||
string tagName, DateTime startTime, DateTime endTime, int maxValues,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
Task<List<DataValue>> ReadAggregateAsync(
|
|
||||||
string tagName, DateTime startTime, DateTime endTime,
|
|
||||||
double intervalMs, string aggregateColumn,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
Task<List<DataValue>> ReadAtTimeAsync(
|
|
||||||
string tagName, DateTime[] timestamps,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
Task<List<HistorianEventDto>> ReadEventsAsync(
|
|
||||||
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns a runtime snapshot of query success/failure counters and connection state.
|
|
||||||
/// Consumed by the status dashboard and health check service so operators can detect
|
|
||||||
/// silent query degradation that the load-time plugin status can't catch.
|
|
||||||
/// </summary>
|
|
||||||
HistorianHealthSnapshot GetHealthSnapshot();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Metrics
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Disposable scope returned by <see cref="PerformanceMetrics.BeginOperation" />. (MXA-008)
|
|
||||||
/// </summary>
|
|
||||||
public interface ITimingScope : IDisposable
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Marks whether the timed bridge operation completed successfully.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="success">A value indicating whether the measured operation succeeded.</param>
|
|
||||||
void SetSuccess(bool success);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Statistics snapshot for a single operation type.
|
|
||||||
/// </summary>
|
|
||||||
public class MetricsStatistics
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the total number of recorded executions for the operation.
|
|
||||||
/// </summary>
|
|
||||||
public long TotalCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of recorded executions that completed successfully.
|
|
||||||
/// </summary>
|
|
||||||
public long SuccessCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the ratio of successful executions to total executions.
|
|
||||||
/// </summary>
|
|
||||||
public double SuccessRate { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the mean execution time in milliseconds across the recorded sample.
|
|
||||||
/// </summary>
|
|
||||||
public double AverageMilliseconds { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the fastest recorded execution time in milliseconds.
|
|
||||||
/// </summary>
|
|
||||||
public double MinMilliseconds { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the slowest recorded execution time in milliseconds.
|
|
||||||
/// </summary>
|
|
||||||
public double MaxMilliseconds { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the 95th percentile execution time in milliseconds.
|
|
||||||
/// </summary>
|
|
||||||
public double Percentile95Milliseconds { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Per-operation timing and success tracking with a 1000-entry rolling buffer. (MXA-008)
|
|
||||||
/// </summary>
|
|
||||||
public class OperationMetrics
|
|
||||||
{
|
|
||||||
private readonly List<double> _durations = new();
|
|
||||||
private readonly object _lock = new();
|
|
||||||
private double _maxMilliseconds;
|
|
||||||
private double _minMilliseconds = double.MaxValue;
|
|
||||||
private long _successCount;
|
|
||||||
private long _totalCount;
|
|
||||||
private double _totalMilliseconds;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Records the outcome and duration of a single bridge operation invocation.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="duration">The elapsed time for the operation.</param>
|
|
||||||
/// <param name="success">A value indicating whether the operation completed successfully.</param>
|
|
||||||
public void Record(TimeSpan duration, bool success)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
_totalCount++;
|
|
||||||
if (success) _successCount++;
|
|
||||||
|
|
||||||
var ms = duration.TotalMilliseconds;
|
|
||||||
_durations.Add(ms);
|
|
||||||
_totalMilliseconds += ms;
|
|
||||||
|
|
||||||
if (ms < _minMilliseconds) _minMilliseconds = ms;
|
|
||||||
if (ms > _maxMilliseconds) _maxMilliseconds = ms;
|
|
||||||
|
|
||||||
if (_durations.Count > 1000) _durations.RemoveAt(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a snapshot of the current statistics for this operation type.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A statistics snapshot suitable for logs, status reporting, and tests.</returns>
|
|
||||||
public MetricsStatistics GetStatistics()
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
if (_totalCount == 0)
|
|
||||||
return new MetricsStatistics();
|
|
||||||
|
|
||||||
var sorted = _durations.OrderBy(d => d).ToList();
|
|
||||||
var p95Index = Math.Max(0, (int)Math.Ceiling(sorted.Count * 0.95) - 1);
|
|
||||||
|
|
||||||
return new MetricsStatistics
|
|
||||||
{
|
|
||||||
TotalCount = _totalCount,
|
|
||||||
SuccessCount = _successCount,
|
|
||||||
SuccessRate = (double)_successCount / _totalCount,
|
|
||||||
AverageMilliseconds = _totalMilliseconds / _totalCount,
|
|
||||||
MinMilliseconds = _minMilliseconds,
|
|
||||||
MaxMilliseconds = _maxMilliseconds,
|
|
||||||
Percentile95Milliseconds = sorted[p95Index]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tracks per-operation performance metrics with periodic logging. (MXA-008)
|
|
||||||
/// </summary>
|
|
||||||
public class PerformanceMetrics : IDisposable
|
|
||||||
{
|
|
||||||
private static readonly ILogger Logger = Log.ForContext<PerformanceMetrics>();
|
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, OperationMetrics>
|
|
||||||
_metrics = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
private readonly Timer _reportingTimer;
|
|
||||||
private bool _disposed;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new metrics collector and starts periodic performance reporting.
|
|
||||||
/// </summary>
|
|
||||||
public PerformanceMetrics()
|
|
||||||
{
|
|
||||||
_reportingTimer = new Timer(ReportMetrics, null,
|
|
||||||
TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops periodic reporting and emits a final metrics snapshot.
|
|
||||||
/// </summary>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (_disposed) return;
|
|
||||||
_disposed = true;
|
|
||||||
_reportingTimer.Dispose();
|
|
||||||
ReportMetrics(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Records a completed bridge operation under the specified metrics bucket.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="operationName">The logical operation name, such as read, write, or subscribe.</param>
|
|
||||||
/// <param name="duration">The elapsed time for the operation.</param>
|
|
||||||
/// <param name="success">A value indicating whether the operation completed successfully.</param>
|
|
||||||
public void RecordOperation(string operationName, TimeSpan duration, bool success = true)
|
|
||||||
{
|
|
||||||
var metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics());
|
|
||||||
metrics.Record(duration, success);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Starts timing a bridge operation and returns a disposable scope that records the result when disposed.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="operationName">The logical operation name to record.</param>
|
|
||||||
/// <returns>A timing scope that reports elapsed time back into this collector.</returns>
|
|
||||||
public ITimingScope BeginOperation(string operationName)
|
|
||||||
{
|
|
||||||
return new TimingScope(this, operationName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves the raw metrics bucket for a named operation.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="operationName">The logical operation name to look up.</param>
|
|
||||||
/// <returns>The metrics bucket when present; otherwise, <see langword="null" />.</returns>
|
|
||||||
public OperationMetrics? GetMetrics(string operationName)
|
|
||||||
{
|
|
||||||
return _metrics.TryGetValue(operationName, out var metrics) ? metrics : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Produces a statistics snapshot for all recorded bridge operations.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A dictionary keyed by operation name containing current metrics statistics.</returns>
|
|
||||||
public Dictionary<string, MetricsStatistics> GetStatistics()
|
|
||||||
{
|
|
||||||
var result = new Dictionary<string, MetricsStatistics>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
foreach (var kvp in _metrics)
|
|
||||||
result[kvp.Key] = kvp.Value.GetStatistics();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReportMetrics(object? state)
|
|
||||||
{
|
|
||||||
foreach (var kvp in _metrics)
|
|
||||||
{
|
|
||||||
var stats = kvp.Value.GetStatistics();
|
|
||||||
if (stats.TotalCount == 0) continue;
|
|
||||||
|
|
||||||
Logger.Information(
|
|
||||||
"Metrics: {Operation} — Count={Count}, SuccessRate={SuccessRate:P1}, " +
|
|
||||||
"AvgMs={AverageMs:F1}, MinMs={MinMs:F1}, MaxMs={MaxMs:F1}, P95Ms={P95Ms:F1}",
|
|
||||||
kvp.Key, stats.TotalCount, stats.SuccessRate,
|
|
||||||
stats.AverageMilliseconds, stats.MinMilliseconds,
|
|
||||||
stats.MaxMilliseconds, stats.Percentile95Milliseconds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Timing scope that records one operation result into the owning metrics collector.
|
|
||||||
/// </summary>
|
|
||||||
private class TimingScope : ITimingScope
|
|
||||||
{
|
|
||||||
private readonly PerformanceMetrics _metrics;
|
|
||||||
private readonly string _operationName;
|
|
||||||
private readonly Stopwatch _stopwatch;
|
|
||||||
private bool _disposed;
|
|
||||||
private bool _success = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a timing scope for a named bridge operation.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="metrics">The metrics collector that should receive the result.</param>
|
|
||||||
/// <param name="operationName">The logical operation name being timed.</param>
|
|
||||||
public TimingScope(PerformanceMetrics metrics, string operationName)
|
|
||||||
{
|
|
||||||
_metrics = metrics;
|
|
||||||
_operationName = operationName;
|
|
||||||
_stopwatch = Stopwatch.StartNew();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Marks whether the timed operation should be recorded as successful.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="success">A value indicating whether the operation succeeded.</param>
|
|
||||||
public void SetSuccess(bool success)
|
|
||||||
{
|
|
||||||
_success = success;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops timing and records the operation result once.
|
|
||||||
/// </summary>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (_disposed) return;
|
|
||||||
_disposed = true;
|
|
||||||
_stopwatch.Stop();
|
|
||||||
_metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Advises <c><ObjectName>.ScanState</c> on every deployed <c>$WinPlatform</c> and
|
|
||||||
/// <c>$AppEngine</c>, tracks their runtime state (Unknown / Running / Stopped), and notifies
|
|
||||||
/// the owning node manager on Running↔Stopped transitions so it can proactively flip every
|
|
||||||
/// OPC UA variable hosted by that object to <c>BadOutOfService</c> (and clear on recovery).
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// State machine semantics are documented in <c>runtimestatus.md</c>. Key facts:
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item><c>ScanState</c> is delivered on-change only — no periodic heartbeat. A stably
|
|
||||||
/// Running host may go hours without a callback.</item>
|
|
||||||
/// <item>Running → Stopped is driven by explicit error callbacks or <c>ScanState = false</c>,
|
|
||||||
/// NEVER by starvation. The only starvation check applies to the initial Unknown state.</item>
|
|
||||||
/// <item>When the MxAccess transport is disconnected, <see cref="GetSnapshot"/> returns every
|
|
||||||
/// entry with <see cref="GalaxyRuntimeState.Unknown"/> regardless of the underlying state,
|
|
||||||
/// because we can't observe anything through a dead transport.</item>
|
|
||||||
/// <item>The stop/start callbacks fire synchronously from whichever thread delivered the
|
|
||||||
/// probe update. The manager releases its own lock before invoking them to avoid
|
|
||||||
/// lock-inversion deadlocks with the node manager's <c>Lock</c>.</item>
|
|
||||||
/// </list>
|
|
||||||
/// </remarks>
|
|
||||||
public sealed class GalaxyRuntimeProbeManager : IDisposable
|
|
||||||
{
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<GalaxyRuntimeProbeManager>();
|
|
||||||
|
|
||||||
private const int CategoryWinPlatform = 1;
|
|
||||||
private const int CategoryAppEngine = 3;
|
|
||||||
private const string KindWinPlatform = "$WinPlatform";
|
|
||||||
private const string KindAppEngine = "$AppEngine";
|
|
||||||
private const string ProbeAttribute = ".ScanState";
|
|
||||||
|
|
||||||
private readonly IMxAccessClient _client;
|
|
||||||
private readonly TimeSpan _unknownTimeout;
|
|
||||||
private readonly Action<int>? _onHostStopped;
|
|
||||||
private readonly Action<int>? _onHostRunning;
|
|
||||||
private readonly Func<DateTime> _clock;
|
|
||||||
|
|
||||||
// Key: probe tag reference (e.g. "DevAppEngine.ScanState").
|
|
||||||
// Value: the current runtime status for that host, kept in sync on every probe callback
|
|
||||||
// and queried via GetSnapshot for dashboard rendering.
|
|
||||||
private readonly Dictionary<string, GalaxyRuntimeStatus> _byProbe =
|
|
||||||
new Dictionary<string, GalaxyRuntimeStatus>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
// Reverse index: gobject_id -> probe tag, so Sync() can diff new/removed hosts efficiently.
|
|
||||||
private readonly Dictionary<int, string> _probeByGobjectId = new Dictionary<int, string>();
|
|
||||||
|
|
||||||
private readonly object _lock = new object();
|
|
||||||
private bool _disposed;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new probe manager. <paramref name="onHostStopped"/> and
|
|
||||||
/// <paramref name="onHostRunning"/> are invoked synchronously on Running↔Stopped
|
|
||||||
/// transitions so the owning node manager can invalidate / restore the hosted subtree.
|
|
||||||
/// </summary>
|
|
||||||
public GalaxyRuntimeProbeManager(
|
|
||||||
IMxAccessClient client,
|
|
||||||
int unknownTimeoutSeconds,
|
|
||||||
Action<int>? onHostStopped = null,
|
|
||||||
Action<int>? onHostRunning = null)
|
|
||||||
: this(client, unknownTimeoutSeconds, onHostStopped, onHostRunning, () => DateTime.UtcNow)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
internal GalaxyRuntimeProbeManager(
|
|
||||||
IMxAccessClient client,
|
|
||||||
int unknownTimeoutSeconds,
|
|
||||||
Action<int>? onHostStopped,
|
|
||||||
Action<int>? onHostRunning,
|
|
||||||
Func<DateTime> clock)
|
|
||||||
{
|
|
||||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
|
||||||
_unknownTimeout = TimeSpan.FromSeconds(Math.Max(1, unknownTimeoutSeconds));
|
|
||||||
_onHostStopped = onHostStopped;
|
|
||||||
_onHostRunning = onHostRunning;
|
|
||||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of active probe subscriptions. Surfaced on the dashboard Subscriptions
|
|
||||||
/// panel so operators can see bridge-owned probe count separately from the total.
|
|
||||||
/// </summary>
|
|
||||||
public int ActiveProbeCount
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
return _byProbe.Count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns <see langword="true"/> when the galaxy runtime host identified by
|
|
||||||
/// <paramref name="gobjectId"/> is currently in the <see cref="GalaxyRuntimeState.Stopped"/>
|
|
||||||
/// state. Used by the node manager's Read path to short-circuit on-demand reads of tags
|
|
||||||
/// hosted by a known-stopped runtime object, preventing MxAccess from serving stale
|
|
||||||
/// cached values as Good. Unlike <see cref="GetSnapshot"/> this check uses the
|
|
||||||
/// underlying state directly — transport-disconnected hosts will NOT report Stopped here
|
|
||||||
/// (they report their last-known state), because connection-loss is handled by the
|
|
||||||
/// normal MxAccess error paths and we don't want this method to double-flag.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsHostStopped(int gobjectId)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
if (_probeByGobjectId.TryGetValue(gobjectId, out var probe)
|
|
||||||
&& _byProbe.TryGetValue(probe, out var status))
|
|
||||||
{
|
|
||||||
return status.State == GalaxyRuntimeState.Stopped;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns a point-in-time clone of the runtime status for the host identified by
|
|
||||||
/// <paramref name="gobjectId"/>, or <see langword="null"/> when no probe is registered
|
|
||||||
/// for that object. Used by the node manager to populate the synthetic <c>$RuntimeState</c>
|
|
||||||
/// child variables on each host object. Uses the underlying state directly (not the
|
|
||||||
/// transport-gated rewrite), matching <see cref="IsHostStopped"/>.
|
|
||||||
/// </summary>
|
|
||||||
public GalaxyRuntimeStatus? GetHostStatus(int gobjectId)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
if (_probeByGobjectId.TryGetValue(gobjectId, out var probe)
|
|
||||||
&& _byProbe.TryGetValue(probe, out var status))
|
|
||||||
{
|
|
||||||
return Clone(status, forceUnknown: false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Diffs the supplied hierarchy against the active probe set, advising new hosts and
|
|
||||||
/// unadvising removed ones. The hierarchy is filtered to runtime host categories
|
|
||||||
/// ($WinPlatform, $AppEngine) — non-host rows are ignored. Idempotent: a second call
|
|
||||||
/// with the same hierarchy performs no Advise / Unadvise work.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Sync is synchronous on MxAccess: <see cref="IMxAccessClient.SubscribeAsync"/> is
|
|
||||||
/// awaited for each new host, so for a galaxy with N runtime hosts the call blocks for
|
|
||||||
/// ~N round-trips. This is acceptable because it only runs during address-space build
|
|
||||||
/// and rebuild, not on the hot path.
|
|
||||||
/// </remarks>
|
|
||||||
public async Task SyncAsync(IReadOnlyList<GalaxyObjectInfo> hierarchy)
|
|
||||||
{
|
|
||||||
if (_disposed || hierarchy == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Filter to runtime hosts and project to the expected probe tag name.
|
|
||||||
var desired = new Dictionary<int, (string Probe, string Kind, GalaxyObjectInfo Obj)>();
|
|
||||||
foreach (var obj in hierarchy)
|
|
||||||
{
|
|
||||||
if (obj.CategoryId != CategoryWinPlatform && obj.CategoryId != CategoryAppEngine)
|
|
||||||
continue;
|
|
||||||
if (string.IsNullOrWhiteSpace(obj.TagName))
|
|
||||||
continue;
|
|
||||||
var probe = obj.TagName + ProbeAttribute;
|
|
||||||
var kind = obj.CategoryId == CategoryWinPlatform ? KindWinPlatform : KindAppEngine;
|
|
||||||
desired[obj.GobjectId] = (probe, kind, obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute diffs under lock, release lock before issuing SDK calls (which can block).
|
|
||||||
// toSubscribe carries the gobject id alongside the probe name so the rollback path on
|
|
||||||
// subscribe failure can unwind both dictionaries without a reverse lookup.
|
|
||||||
List<(int GobjectId, string Probe)> toSubscribe;
|
|
||||||
List<string> toUnsubscribe;
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
toSubscribe = new List<(int, string)>();
|
|
||||||
toUnsubscribe = new List<string>();
|
|
||||||
|
|
||||||
foreach (var kvp in desired)
|
|
||||||
{
|
|
||||||
if (_probeByGobjectId.TryGetValue(kvp.Key, out var existingProbe))
|
|
||||||
{
|
|
||||||
// Already tracked: ensure the status entry is aligned (tag rename path is
|
|
||||||
// intentionally not supported — if the probe changed, treat it as remove+add).
|
|
||||||
if (!string.Equals(existingProbe, kvp.Value.Probe, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
toUnsubscribe.Add(existingProbe);
|
|
||||||
_byProbe.Remove(existingProbe);
|
|
||||||
_probeByGobjectId.Remove(kvp.Key);
|
|
||||||
|
|
||||||
toSubscribe.Add((kvp.Key, kvp.Value.Probe));
|
|
||||||
_byProbe[kvp.Value.Probe] = MakeInitialStatus(kvp.Value.Obj, kvp.Value.Kind);
|
|
||||||
_probeByGobjectId[kvp.Key] = kvp.Value.Probe;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
toSubscribe.Add((kvp.Key, kvp.Value.Probe));
|
|
||||||
_byProbe[kvp.Value.Probe] = MakeInitialStatus(kvp.Value.Obj, kvp.Value.Kind);
|
|
||||||
_probeByGobjectId[kvp.Key] = kvp.Value.Probe;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove hosts that are no longer in the desired set.
|
|
||||||
var toRemove = _probeByGobjectId.Keys.Where(id => !desired.ContainsKey(id)).ToList();
|
|
||||||
foreach (var id in toRemove)
|
|
||||||
{
|
|
||||||
var probe = _probeByGobjectId[id];
|
|
||||||
toUnsubscribe.Add(probe);
|
|
||||||
_byProbe.Remove(probe);
|
|
||||||
_probeByGobjectId.Remove(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply the diff outside the lock.
|
|
||||||
foreach (var (gobjectId, probe) in toSubscribe)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _client.SubscribeAsync(probe, OnProbeValueChanged);
|
|
||||||
Log.Information("Galaxy runtime probe advised: {Probe}", probe);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Failed to advise galaxy runtime probe {Probe}", probe);
|
|
||||||
|
|
||||||
// Roll back the pending entry so Tick() can't later transition a never-advised
|
|
||||||
// probe from Unknown to Stopped and fan out a false-negative host-down signal.
|
|
||||||
// A concurrent SyncAsync may have re-added the same gobject under a new probe
|
|
||||||
// name, so compare against the captured probe string before removing.
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
if (_probeByGobjectId.TryGetValue(gobjectId, out var current)
|
|
||||||
&& string.Equals(current, probe, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
_probeByGobjectId.Remove(gobjectId);
|
|
||||||
}
|
|
||||||
_byProbe.Remove(probe);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var probe in toUnsubscribe)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _client.UnsubscribeAsync(probe);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Debug(ex, "Failed to unadvise galaxy runtime probe {Probe} during sync", probe);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Routes an <c>OnTagValueChanged</c> callback to the probe state machine. Returns
|
|
||||||
/// <see langword="true"/> when <paramref name="tagRef"/> matches a bridge-owned probe
|
|
||||||
/// (in which case the owning node manager should skip its normal variable-update path).
|
|
||||||
/// </summary>
|
|
||||||
public bool HandleProbeUpdate(string tagRef, Vtq vtq)
|
|
||||||
{
|
|
||||||
if (_disposed || string.IsNullOrEmpty(tagRef))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
GalaxyRuntimeStatus? status;
|
|
||||||
int fromToGobjectId = 0;
|
|
||||||
GalaxyRuntimeState? transitionTo = null;
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
if (!_byProbe.TryGetValue(tagRef, out status))
|
|
||||||
return false; // not a probe — let the caller handle it normally
|
|
||||||
|
|
||||||
var now = _clock();
|
|
||||||
var isRunning = vtq.Quality.IsGood() && vtq.Value is bool b && b;
|
|
||||||
status.LastStateCallbackTime = now;
|
|
||||||
status.LastScanState = vtq.Value as bool?;
|
|
||||||
|
|
||||||
if (isRunning)
|
|
||||||
{
|
|
||||||
status.GoodUpdateCount++;
|
|
||||||
status.LastError = null;
|
|
||||||
if (status.State != GalaxyRuntimeState.Running)
|
|
||||||
{
|
|
||||||
// Only fire the host-running callback on a true Stopped → Running
|
|
||||||
// recovery. Unknown → Running happens once at startup for every host
|
|
||||||
// and is not a recovery — firing ClearHostVariablesBadQuality there
|
|
||||||
// would wipe Bad status set by the concurrently-stopping other host
|
|
||||||
// on variables that span both lists.
|
|
||||||
var wasStopped = status.State == GalaxyRuntimeState.Stopped;
|
|
||||||
status.State = GalaxyRuntimeState.Running;
|
|
||||||
status.LastStateChangeTime = now;
|
|
||||||
if (wasStopped)
|
|
||||||
{
|
|
||||||
transitionTo = GalaxyRuntimeState.Running;
|
|
||||||
fromToGobjectId = status.GobjectId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
status.FailureCount++;
|
|
||||||
status.LastError = BuildErrorDetail(vtq);
|
|
||||||
if (status.State != GalaxyRuntimeState.Stopped)
|
|
||||||
{
|
|
||||||
status.State = GalaxyRuntimeState.Stopped;
|
|
||||||
status.LastStateChangeTime = now;
|
|
||||||
transitionTo = GalaxyRuntimeState.Stopped;
|
|
||||||
fromToGobjectId = status.GobjectId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invoke transition callbacks outside the lock to avoid inverting the node manager's
|
|
||||||
// lock order when it subsequently takes its own Lock to flip hosted variables.
|
|
||||||
if (transitionTo == GalaxyRuntimeState.Stopped)
|
|
||||||
{
|
|
||||||
Log.Information("Galaxy runtime {Probe} transitioned Running → Stopped ({Err})",
|
|
||||||
tagRef, status?.LastError ?? "(no detail)");
|
|
||||||
try { _onHostStopped?.Invoke(fromToGobjectId); }
|
|
||||||
catch (Exception ex) { Log.Warning(ex, "onHostStopped callback threw for {Probe}", tagRef); }
|
|
||||||
}
|
|
||||||
else if (transitionTo == GalaxyRuntimeState.Running)
|
|
||||||
{
|
|
||||||
Log.Information("Galaxy runtime {Probe} transitioned → Running", tagRef);
|
|
||||||
try { _onHostRunning?.Invoke(fromToGobjectId); }
|
|
||||||
catch (Exception ex) { Log.Warning(ex, "onHostRunning callback threw for {Probe}", tagRef); }
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Periodic tick — flips Unknown entries to Stopped once their registration has been
|
|
||||||
/// outstanding for longer than the configured timeout without ever receiving a first
|
|
||||||
/// callback. Does nothing to Running or Stopped entries.
|
|
||||||
/// </summary>
|
|
||||||
public void Tick()
|
|
||||||
{
|
|
||||||
if (_disposed)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var transitions = new List<int>();
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
var now = _clock();
|
|
||||||
foreach (var entry in _byProbe.Values)
|
|
||||||
{
|
|
||||||
if (entry.State != GalaxyRuntimeState.Unknown)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// LastStateChangeTime is set at creation to "now" so the timeout is measured
|
|
||||||
// from when the probe was advised.
|
|
||||||
if (entry.LastStateChangeTime.HasValue
|
|
||||||
&& now - entry.LastStateChangeTime.Value > _unknownTimeout)
|
|
||||||
{
|
|
||||||
entry.State = GalaxyRuntimeState.Stopped;
|
|
||||||
entry.LastStateChangeTime = now;
|
|
||||||
entry.FailureCount++;
|
|
||||||
entry.LastError = "Probe never received an initial callback within the unknown-resolution timeout";
|
|
||||||
transitions.Add(entry.GobjectId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var gobjectId in transitions)
|
|
||||||
{
|
|
||||||
Log.Warning("Galaxy runtime gobject {GobjectId} timed out in Unknown state → Stopped", gobjectId);
|
|
||||||
try { _onHostStopped?.Invoke(gobjectId); }
|
|
||||||
catch (Exception ex) { Log.Warning(ex, "onHostStopped callback threw during tick for {GobjectId}", gobjectId); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns a read-only snapshot of every tracked host. When the MxAccess transport is
|
|
||||||
/// disconnected, every entry is rewritten to Unknown on the way out so operators aren't
|
|
||||||
/// misled by cached per-host state — the Connection panel is the primary signal in that
|
|
||||||
/// case. The underlying <c>_byProbe</c> map is not modified.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<GalaxyRuntimeStatus> GetSnapshot()
|
|
||||||
{
|
|
||||||
var transportDown = _client.State != ConnectionState.Connected;
|
|
||||||
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
var result = new List<GalaxyRuntimeStatus>(_byProbe.Count);
|
|
||||||
foreach (var entry in _byProbe.Values)
|
|
||||||
result.Add(Clone(entry, forceUnknown: transportDown));
|
|
||||||
// Stable ordering by name so dashboard rows don't jitter between refreshes.
|
|
||||||
result.Sort((a, b) => string.CompareOrdinal(a.ObjectName, b.ObjectName));
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
List<string> probes;
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
if (_disposed)
|
|
||||||
return;
|
|
||||||
_disposed = true;
|
|
||||||
probes = _byProbe.Keys.ToList();
|
|
||||||
_byProbe.Clear();
|
|
||||||
_probeByGobjectId.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var probe in probes)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_client.UnsubscribeAsync(probe).GetAwaiter().GetResult();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Debug(ex, "Failed to unadvise galaxy runtime probe {Probe} during Dispose", probe);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnProbeValueChanged(string tagRef, Vtq vtq)
|
|
||||||
{
|
|
||||||
HandleProbeUpdate(tagRef, vtq);
|
|
||||||
}
|
|
||||||
|
|
||||||
private GalaxyRuntimeStatus MakeInitialStatus(GalaxyObjectInfo obj, string kind)
|
|
||||||
{
|
|
||||||
return new GalaxyRuntimeStatus
|
|
||||||
{
|
|
||||||
ObjectName = obj.TagName,
|
|
||||||
GobjectId = obj.GobjectId,
|
|
||||||
Kind = kind,
|
|
||||||
State = GalaxyRuntimeState.Unknown,
|
|
||||||
LastStateChangeTime = _clock()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static GalaxyRuntimeStatus Clone(GalaxyRuntimeStatus src, bool forceUnknown)
|
|
||||||
{
|
|
||||||
return new GalaxyRuntimeStatus
|
|
||||||
{
|
|
||||||
ObjectName = src.ObjectName,
|
|
||||||
GobjectId = src.GobjectId,
|
|
||||||
Kind = src.Kind,
|
|
||||||
State = forceUnknown ? GalaxyRuntimeState.Unknown : src.State,
|
|
||||||
LastStateCallbackTime = src.LastStateCallbackTime,
|
|
||||||
LastStateChangeTime = src.LastStateChangeTime,
|
|
||||||
LastScanState = src.LastScanState,
|
|
||||||
LastError = forceUnknown ? null : src.LastError,
|
|
||||||
GoodUpdateCount = src.GoodUpdateCount,
|
|
||||||
FailureCount = src.FailureCount
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildErrorDetail(Vtq vtq)
|
|
||||||
{
|
|
||||||
if (vtq.Quality.IsBad())
|
|
||||||
return $"bad quality ({vtq.Quality})";
|
|
||||||
if (vtq.Quality.IsUncertain())
|
|
||||||
return $"uncertain quality ({vtq.Quality})";
|
|
||||||
if (vtq.Value is bool b && !b)
|
|
||||||
return "ScanState = false (OffScan)";
|
|
||||||
return $"unexpected value: {vtq.Value ?? "(null)"}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
|
|
||||||
{
|
|
||||||
public sealed partial class MxAccessClient
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Opens the MXAccess runtime connection, replays stored subscriptions, and starts the optional probe subscription.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A token that cancels the connection attempt.</param>
|
|
||||||
public async Task ConnectAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (_state == ConnectionState.Connected) return;
|
|
||||||
|
|
||||||
SetState(ConnectionState.Connecting);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_connectionHandle = await _staThread.RunAsync(() =>
|
|
||||||
{
|
|
||||||
AttachProxyEvents();
|
|
||||||
return _proxy.Register(_config.ClientName);
|
|
||||||
});
|
|
||||||
|
|
||||||
Log.Information("MxAccess registered with handle {Handle}", _connectionHandle);
|
|
||||||
SetState(ConnectionState.Connected);
|
|
||||||
|
|
||||||
// Replay stored subscriptions
|
|
||||||
await ReplayStoredSubscriptionsAsync();
|
|
||||||
|
|
||||||
// Start probe if configured
|
|
||||||
if (!string.IsNullOrWhiteSpace(_config.ProbeTag))
|
|
||||||
{
|
|
||||||
_probeTag = _config.ProbeTag;
|
|
||||||
_lastProbeValueTime = DateTime.UtcNow;
|
|
||||||
await SubscribeInternalAsync(_probeTag!);
|
|
||||||
Log.Information("Probe tag subscribed: {ProbeTag}", _probeTag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _staThread.RunAsync(DetachProxyEvents);
|
|
||||||
}
|
|
||||||
catch (Exception cleanupEx)
|
|
||||||
{
|
|
||||||
Log.Warning(cleanupEx, "Failed to detach proxy events after connection failure");
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Error(ex, "MxAccess connection failed");
|
|
||||||
SetState(ConnectionState.Error, ex.Message);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Disconnects from the runtime and cleans up active handles, callbacks, and pending operations.
|
|
||||||
/// </summary>
|
|
||||||
public async Task DisconnectAsync()
|
|
||||||
{
|
|
||||||
if (_state == ConnectionState.Disconnected) return;
|
|
||||||
|
|
||||||
SetState(ConnectionState.Disconnecting);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _staThread.RunAsync(() =>
|
|
||||||
{
|
|
||||||
// UnAdvise + RemoveItem for all active subscriptions
|
|
||||||
foreach (var kvp in _addressToHandle)
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_proxy.UnAdviseSupervisory(_connectionHandle, kvp.Value);
|
|
||||||
_proxy.RemoveItem(_connectionHandle, kvp.Value);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Error cleaning up subscription for {Address}", kvp.Key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwire events before unregister
|
|
||||||
DetachProxyEvents();
|
|
||||||
|
|
||||||
// Unregister
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_proxy.Unregister(_connectionHandle);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Error during Unregister");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
_handleToAddress.Clear();
|
|
||||||
_addressToHandle.Clear();
|
|
||||||
_pendingReadsByAddress.Clear();
|
|
||||||
_pendingWrites.Clear();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Error during disconnect");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
SetState(ConnectionState.Disconnected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attempts to recover from a runtime fault by disconnecting and reconnecting the client.
|
|
||||||
/// </summary>
|
|
||||||
public async Task ReconnectAsync()
|
|
||||||
{
|
|
||||||
SetState(ConnectionState.Reconnecting);
|
|
||||||
Interlocked.Increment(ref _reconnectCount);
|
|
||||||
Log.Information("MxAccess reconnect attempt #{Count}", _reconnectCount);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await DisconnectAsync();
|
|
||||||
await ConnectAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Reconnect failed");
|
|
||||||
SetState(ConnectionState.Error, ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AttachProxyEvents()
|
|
||||||
{
|
|
||||||
if (_proxyEventsAttached) return;
|
|
||||||
_proxy.OnDataChange += HandleOnDataChange;
|
|
||||||
_proxy.OnWriteComplete += HandleOnWriteComplete;
|
|
||||||
_proxyEventsAttached = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DetachProxyEvents()
|
|
||||||
{
|
|
||||||
if (!_proxyEventsAttached) return;
|
|
||||||
_proxy.OnDataChange -= HandleOnDataChange;
|
|
||||||
_proxy.OnWriteComplete -= HandleOnWriteComplete;
|
|
||||||
_proxyEventsAttached = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
using System;
|
|
||||||
using ArchestrA.MxAccess;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
|
|
||||||
{
|
|
||||||
public sealed partial class MxAccessClient
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// COM event handler for MxAccess OnDataChange events.
|
|
||||||
/// Signature matches the ArchestrA.MxAccess ILMXProxyServerEvents interface.
|
|
||||||
/// </summary>
|
|
||||||
private void HandleOnDataChange(
|
|
||||||
int hLMXServerHandle,
|
|
||||||
int phItemHandle,
|
|
||||||
object pvItemValue,
|
|
||||||
int pwItemQuality,
|
|
||||||
object pftItemTimeStamp,
|
|
||||||
ref MXSTATUS_PROXY[] ItemStatus)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!_handleToAddress.TryGetValue(phItemHandle, out var address))
|
|
||||||
{
|
|
||||||
Log.Debug("OnDataChange for unknown handle {Handle}", phItemHandle);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var quality = QualityMapper.MapFromMxAccessQuality(pwItemQuality);
|
|
||||||
|
|
||||||
// Check MXSTATUS_PROXY — if success is false, use more specific quality
|
|
||||||
if (ItemStatus != null && ItemStatus.Length > 0 && ItemStatus[0].success == 0)
|
|
||||||
quality = MxErrorCodes.MapToQuality(ItemStatus[0].detail);
|
|
||||||
|
|
||||||
var timestamp = ConvertTimestamp(pftItemTimeStamp);
|
|
||||||
var vtq = new Vtq(pvItemValue, timestamp, quality);
|
|
||||||
|
|
||||||
// Update probe timestamp
|
|
||||||
if (string.Equals(address, _probeTag, StringComparison.OrdinalIgnoreCase))
|
|
||||||
_lastProbeValueTime = DateTime.UtcNow;
|
|
||||||
|
|
||||||
// Invoke stored subscription callback
|
|
||||||
if (_storedSubscriptions.TryGetValue(address, out var callback)) callback(address, vtq);
|
|
||||||
|
|
||||||
if (_pendingReadsByAddress.TryGetValue(address, out var pendingReads))
|
|
||||||
foreach (var pendingRead in pendingReads.Values)
|
|
||||||
pendingRead.TrySetResult(vtq);
|
|
||||||
|
|
||||||
// Global handler
|
|
||||||
OnTagValueChanged?.Invoke(address, vtq);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Error processing OnDataChange for handle {Handle}", phItemHandle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// COM event handler for MxAccess OnWriteComplete events.
|
|
||||||
/// </summary>
|
|
||||||
private void HandleOnWriteComplete(
|
|
||||||
int hLMXServerHandle,
|
|
||||||
int phItemHandle,
|
|
||||||
ref MXSTATUS_PROXY[] ItemStatus)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_pendingWrites.TryRemove(phItemHandle, out var tcs))
|
|
||||||
{
|
|
||||||
var success = ItemStatus == null || ItemStatus.Length == 0 || ItemStatus[0].success != 0;
|
|
||||||
if (success)
|
|
||||||
{
|
|
||||||
tcs.TrySetResult(true);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var detail = ItemStatus![0].detail;
|
|
||||||
var message = MxErrorCodes.GetMessage(detail);
|
|
||||||
Log.Warning("Write failed for handle {Handle}: {Message}", phItemHandle, message);
|
|
||||||
tcs.TrySetResult(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Error processing OnWriteComplete for handle {Handle}", phItemHandle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DateTime ConvertTimestamp(object pftItemTimeStamp)
|
|
||||||
{
|
|
||||||
if (pftItemTimeStamp is DateTime dt)
|
|
||||||
return dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime();
|
|
||||||
return DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
|
|
||||||
{
|
|
||||||
public sealed partial class MxAccessClient
|
|
||||||
{
|
|
||||||
private Task? _monitorTask;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Starts the background monitor that reconnects dropped sessions and watches the probe tag for staleness.
|
|
||||||
/// </summary>
|
|
||||||
public void StartMonitor()
|
|
||||||
{
|
|
||||||
if (_monitorCts != null)
|
|
||||||
StopMonitor();
|
|
||||||
|
|
||||||
_monitorCts = new CancellationTokenSource();
|
|
||||||
_monitorTask = Task.Run(() => MonitorLoopAsync(_monitorCts.Token));
|
|
||||||
Log.Information("MxAccess monitor started (interval={Interval}s)", _config.MonitorIntervalSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops the background monitor loop.
|
|
||||||
/// </summary>
|
|
||||||
public void StopMonitor()
|
|
||||||
{
|
|
||||||
_monitorCts?.Cancel();
|
|
||||||
try { _monitorTask?.Wait(TimeSpan.FromSeconds(5)); } catch { /* timeout or faulted */ }
|
|
||||||
_monitorTask = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task MonitorLoopAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
while (!ct.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(_config.MonitorIntervalSeconds), ct);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if ((_state == ConnectionState.Disconnected || _state == ConnectionState.Error) &&
|
|
||||||
_config.AutoReconnect)
|
|
||||||
{
|
|
||||||
Log.Information("Monitor: connection lost (state={State}), attempting reconnect", _state);
|
|
||||||
await ReconnectAsync();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_state == ConnectionState.Connected && _probeTag != null)
|
|
||||||
{
|
|
||||||
var elapsed = DateTime.UtcNow - _lastProbeValueTime;
|
|
||||||
if (elapsed.TotalSeconds > _config.ProbeStaleThresholdSeconds)
|
|
||||||
{
|
|
||||||
Log.Warning("Monitor: probe stale ({Elapsed:F0}s > {Threshold}s), forcing reconnect",
|
|
||||||
elapsed.TotalSeconds, _config.ProbeStaleThresholdSeconds);
|
|
||||||
await ReconnectAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Monitor loop error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Information("MxAccess monitor stopped");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
|
|
||||||
{
|
|
||||||
public sealed partial class MxAccessClient
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Performs a one-shot read of a Galaxy tag by waiting for the next runtime data-change callback.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to read.</param>
|
|
||||||
/// <param name="ct">A token that cancels the read.</param>
|
|
||||||
/// <returns>The resulting VTQ value or a bad-quality fallback on timeout or failure.</returns>
|
|
||||||
public async Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (_state != ConnectionState.Connected)
|
|
||||||
return Vtq.Bad(Quality.BadNotConnected);
|
|
||||||
|
|
||||||
await _operationSemaphore.WaitAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var scope = _metrics.BeginOperation("Read");
|
|
||||||
var tcs = new TaskCompletionSource<Vtq>();
|
|
||||||
|
|
||||||
var itemHandle = await _staThread.RunAsync(() =>
|
|
||||||
{
|
|
||||||
var h = _proxy.AddItem(_connectionHandle, fullTagReference);
|
|
||||||
_proxy.AdviseSupervisory(_connectionHandle, h);
|
|
||||||
return h;
|
|
||||||
});
|
|
||||||
|
|
||||||
var pendingReads = _pendingReadsByAddress.GetOrAdd(fullTagReference,
|
|
||||||
_ => new ConcurrentDictionary<int, TaskCompletionSource<Vtq>>());
|
|
||||||
pendingReads[itemHandle] = tcs;
|
|
||||||
_handleToAddress[itemHandle] = fullTagReference;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
||||||
cts.CancelAfter(TimeSpan.FromSeconds(_config.ReadTimeoutSeconds));
|
|
||||||
cts.Token.Register(() => tcs.TrySetResult(Vtq.Bad(Quality.BadCommFailure)));
|
|
||||||
|
|
||||||
var result = await tcs.Task;
|
|
||||||
if (result.Quality != Quality.Good)
|
|
||||||
scope.SetSuccess(false);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
scope.SetSuccess(false);
|
|
||||||
return Vtq.Bad(Quality.BadCommFailure);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (_pendingReadsByAddress.TryGetValue(fullTagReference, out var reads))
|
|
||||||
{
|
|
||||||
reads.TryRemove(itemHandle, out _);
|
|
||||||
if (reads.IsEmpty)
|
|
||||||
_pendingReadsByAddress.TryRemove(fullTagReference, out _);
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleToAddress.TryRemove(itemHandle, out _);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _staThread.RunAsync(() =>
|
|
||||||
{
|
|
||||||
_proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
|
|
||||||
_proxy.RemoveItem(_connectionHandle, itemHandle);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Error cleaning up read subscription for {Address}", fullTagReference);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_operationSemaphore.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes a value to a Galaxy tag and waits for the runtime write-complete callback.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to write.</param>
|
|
||||||
/// <param name="value">The value to send to the runtime.</param>
|
|
||||||
/// <param name="ct">A token that cancels the write.</param>
|
|
||||||
/// <returns><see langword="true" /> when the runtime acknowledges success; otherwise, <see langword="false" />.</returns>
|
|
||||||
public async Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (_state != ConnectionState.Connected) return false;
|
|
||||||
|
|
||||||
await _operationSemaphore.WaitAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var scope = _metrics.BeginOperation("Write");
|
|
||||||
|
|
||||||
var itemHandle = await _staThread.RunAsync(() =>
|
|
||||||
{
|
|
||||||
var h = _proxy.AddItem(_connectionHandle, fullTagReference);
|
|
||||||
_proxy.AdviseSupervisory(_connectionHandle, h);
|
|
||||||
return h;
|
|
||||||
});
|
|
||||||
|
|
||||||
_handleToAddress[itemHandle] = fullTagReference;
|
|
||||||
|
|
||||||
var tcs = new TaskCompletionSource<bool>();
|
|
||||||
_pendingWrites[itemHandle] = tcs;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _staThread.RunAsync(() => _proxy.Write(_connectionHandle, itemHandle, value, -1));
|
|
||||||
|
|
||||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
||||||
cts.CancelAfter(TimeSpan.FromSeconds(_config.WriteTimeoutSeconds));
|
|
||||||
cts.Token.Register(() =>
|
|
||||||
{
|
|
||||||
Log.Warning("Write timed out for {Address} after {Timeout}s", fullTagReference,
|
|
||||||
_config.WriteTimeoutSeconds);
|
|
||||||
tcs.TrySetResult(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
var success = await tcs.Task;
|
|
||||||
if (!success)
|
|
||||||
scope.SetSuccess(false);
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
scope.SetSuccess(false);
|
|
||||||
Log.Error(ex, "Write failed for {Address}", fullTagReference);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_pendingWrites.TryRemove(itemHandle, out _);
|
|
||||||
_handleToAddress.TryRemove(itemHandle, out _);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _staThread.RunAsync(() =>
|
|
||||||
{
|
|
||||||
_proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
|
|
||||||
_proxy.RemoveItem(_connectionHandle, itemHandle);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Error cleaning up write subscription for {Address}", fullTagReference);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_operationSemaphore.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
|
|
||||||
{
|
|
||||||
public sealed partial class MxAccessClient
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Registers a persistent subscription callback for a Galaxy tag and activates it immediately when connected.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to monitor.</param>
|
|
||||||
/// <param name="callback">The callback that should receive runtime value changes.</param>
|
|
||||||
public async Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback)
|
|
||||||
{
|
|
||||||
_storedSubscriptions[fullTagReference] = callback;
|
|
||||||
if (_state != ConnectionState.Connected) return;
|
|
||||||
if (_addressToHandle.ContainsKey(fullTagReference)) return;
|
|
||||||
|
|
||||||
await SubscribeInternalAsync(fullTagReference);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Removes a persistent subscription callback and tears down the runtime item when appropriate.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fullTagReference">The fully qualified Galaxy tag reference to stop monitoring.</param>
|
|
||||||
public async Task UnsubscribeAsync(string fullTagReference)
|
|
||||||
{
|
|
||||||
_storedSubscriptions.TryRemove(fullTagReference, out _);
|
|
||||||
|
|
||||||
// Don't unsubscribe the probe tag
|
|
||||||
if (string.Equals(fullTagReference, _probeTag, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (_addressToHandle.TryRemove(fullTagReference, out var itemHandle))
|
|
||||||
{
|
|
||||||
_handleToAddress.TryRemove(itemHandle, out _);
|
|
||||||
|
|
||||||
if (_state == ConnectionState.Connected)
|
|
||||||
await _staThread.RunAsync(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
|
|
||||||
_proxy.RemoveItem(_connectionHandle, itemHandle);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Error unsubscribing {Address}", fullTagReference);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SubscribeInternalAsync(string address)
|
|
||||||
{
|
|
||||||
if (_addressToHandle.ContainsKey(address))
|
|
||||||
return;
|
|
||||||
|
|
||||||
using var scope = _metrics.BeginOperation("Subscribe");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var itemHandle = await _staThread.RunAsync(() =>
|
|
||||||
{
|
|
||||||
var h = _proxy.AddItem(_connectionHandle, address);
|
|
||||||
_proxy.AdviseSupervisory(_connectionHandle, h);
|
|
||||||
return h;
|
|
||||||
});
|
|
||||||
|
|
||||||
var registeredHandle = _addressToHandle.GetOrAdd(address, itemHandle);
|
|
||||||
if (registeredHandle != itemHandle)
|
|
||||||
{
|
|
||||||
await _staThread.RunAsync(() =>
|
|
||||||
{
|
|
||||||
_proxy.UnAdviseSupervisory(_connectionHandle, itemHandle);
|
|
||||||
_proxy.RemoveItem(_connectionHandle, itemHandle);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleToAddress[itemHandle] = address;
|
|
||||||
Log.Debug("Subscribed to {Address} (handle={Handle})", address, itemHandle);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
scope.SetSuccess(false);
|
|
||||||
Log.Error(ex, "Failed to subscribe to {Address}", address);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ReplayStoredSubscriptionsAsync()
|
|
||||||
{
|
|
||||||
foreach (var kvp in _storedSubscriptions)
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await SubscribeInternalAsync(kvp.Key);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Failed to replay subscription for {Address}", kvp.Key);
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Information("Replayed {Count} stored subscriptions", _storedSubscriptions.Count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Core MXAccess client implementing IMxAccessClient via IMxProxy abstraction.
|
|
||||||
/// Split across partial classes: Connection, Subscription, ReadWrite, EventHandlers, Monitor.
|
|
||||||
/// (MXA-001 through MXA-009)
|
|
||||||
/// </summary>
|
|
||||||
public sealed partial class MxAccessClient : IMxAccessClient
|
|
||||||
{
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<MxAccessClient>();
|
|
||||||
private readonly ConcurrentDictionary<string, int> _addressToHandle = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
private readonly MxAccessConfiguration _config;
|
|
||||||
|
|
||||||
// Handle mappings
|
|
||||||
private readonly ConcurrentDictionary<int, string> _handleToAddress = new();
|
|
||||||
private readonly PerformanceMetrics _metrics;
|
|
||||||
private readonly SemaphoreSlim _operationSemaphore;
|
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, TaskCompletionSource<Vtq>>>
|
|
||||||
_pendingReadsByAddress
|
|
||||||
= new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
// Pending writes
|
|
||||||
private readonly ConcurrentDictionary<int, TaskCompletionSource<bool>> _pendingWrites = new();
|
|
||||||
|
|
||||||
private readonly IMxProxy _proxy;
|
|
||||||
|
|
||||||
private readonly StaComThread _staThread;
|
|
||||||
|
|
||||||
// Subscription storage
|
|
||||||
private readonly ConcurrentDictionary<string, Action<string, Vtq>> _storedSubscriptions
|
|
||||||
= new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
private int _connectionHandle;
|
|
||||||
private DateTime _lastProbeValueTime = DateTime.UtcNow;
|
|
||||||
private CancellationTokenSource? _monitorCts;
|
|
||||||
|
|
||||||
// Probe
|
|
||||||
private string? _probeTag;
|
|
||||||
private bool _proxyEventsAttached;
|
|
||||||
private int _reconnectCount;
|
|
||||||
private volatile ConnectionState _state = ConnectionState.Disconnected;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new MXAccess client around the STA thread, COM proxy abstraction, and runtime throttling settings.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="staThread">The STA thread used to marshal COM interactions.</param>
|
|
||||||
/// <param name="proxy">The COM proxy abstraction used to talk to the runtime.</param>
|
|
||||||
/// <param name="config">The runtime timeout, throttling, and reconnect settings.</param>
|
|
||||||
/// <param name="metrics">The metrics collector used to time MXAccess operations.</param>
|
|
||||||
public MxAccessClient(StaComThread staThread, IMxProxy proxy, MxAccessConfiguration config,
|
|
||||||
PerformanceMetrics metrics)
|
|
||||||
{
|
|
||||||
_staThread = staThread;
|
|
||||||
_proxy = proxy;
|
|
||||||
_config = config;
|
|
||||||
_metrics = metrics;
|
|
||||||
_operationSemaphore = new SemaphoreSlim(config.MaxConcurrentOperations, config.MaxConcurrentOperations);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current runtime connection state for the MXAccess client.
|
|
||||||
/// </summary>
|
|
||||||
public ConnectionState State => _state;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of active tag subscriptions currently maintained against the runtime.
|
|
||||||
/// </summary>
|
|
||||||
public int ActiveSubscriptionCount => _storedSubscriptions.Count;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of reconnect attempts performed since the client was created.
|
|
||||||
/// </summary>
|
|
||||||
public int ReconnectCount => _reconnectCount;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when the MXAccess connection state changes.
|
|
||||||
/// </summary>
|
|
||||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when a subscribed runtime tag publishes a new value.
|
|
||||||
/// </summary>
|
|
||||||
public event Action<string, Vtq>? OnTagValueChanged;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Cancels monitoring and disconnects the runtime session before releasing local resources.
|
|
||||||
/// </summary>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_monitorCts?.Cancel();
|
|
||||||
DisconnectAsync().GetAwaiter().GetResult();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Error during MxAccessClient dispose");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_operationSemaphore.Dispose();
|
|
||||||
_monitorCts?.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetState(ConnectionState newState, string message = "")
|
|
||||||
{
|
|
||||||
var previous = _state;
|
|
||||||
if (previous == newState) return;
|
|
||||||
_state = newState;
|
|
||||||
Log.Information("MxAccess state: {Previous} → {Current} {Message}", previous, newState, message);
|
|
||||||
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previous, newState, message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using ArchestrA.MxAccess;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Wraps the real ArchestrA.MxAccess.LMXProxyServer COM object, forwarding calls to IMxProxy.
|
|
||||||
/// Uses strongly-typed interop — same pattern as the reference LmxProxy implementation. (MXA-001)
|
|
||||||
/// </summary>
|
|
||||||
public sealed class MxProxyAdapter : IMxProxy
|
|
||||||
{
|
|
||||||
private LMXProxyServer? _lmxProxy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when the COM proxy publishes a live data-change callback for a subscribed Galaxy attribute.
|
|
||||||
/// </summary>
|
|
||||||
public event MxDataChangeHandler? OnDataChange;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when the COM proxy confirms completion of a write request.
|
|
||||||
/// </summary>
|
|
||||||
public event MxWriteCompleteHandler? OnWriteComplete;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates and registers the COM proxy session that backs live MXAccess operations.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="clientName">The client name reported to the Wonderware runtime.</param>
|
|
||||||
/// <returns>The runtime connection handle assigned by the COM server.</returns>
|
|
||||||
public int Register(string clientName)
|
|
||||||
{
|
|
||||||
_lmxProxy = new LMXProxyServer();
|
|
||||||
|
|
||||||
_lmxProxy.OnDataChange += ProxyOnDataChange;
|
|
||||||
_lmxProxy.OnWriteComplete += ProxyOnWriteComplete;
|
|
||||||
|
|
||||||
var handle = _lmxProxy.Register(clientName);
|
|
||||||
if (handle <= 0)
|
|
||||||
throw new InvalidOperationException($"LMXProxyServer.Register returned invalid handle: {handle}");
|
|
||||||
|
|
||||||
return handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unregisters the COM proxy session and releases the underlying COM object.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="handle">The runtime connection handle returned by <see cref="Register(string)" />.</param>
|
|
||||||
public void Unregister(int handle)
|
|
||||||
{
|
|
||||||
if (_lmxProxy != null)
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_lmxProxy.OnDataChange -= ProxyOnDataChange;
|
|
||||||
_lmxProxy.OnWriteComplete -= ProxyOnWriteComplete;
|
|
||||||
_lmxProxy.Unregister(handle);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Marshal.ReleaseComObject(_lmxProxy);
|
|
||||||
_lmxProxy = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resolves a Galaxy attribute reference into a runtime item handle through the COM proxy.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="handle">The runtime connection handle.</param>
|
|
||||||
/// <param name="address">The fully qualified Galaxy attribute reference.</param>
|
|
||||||
/// <returns>The item handle assigned by the COM proxy.</returns>
|
|
||||||
public int AddItem(int handle, string address)
|
|
||||||
{
|
|
||||||
return _lmxProxy!.AddItem(handle, address);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Removes an item handle from the active COM proxy session.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="handle">The runtime connection handle.</param>
|
|
||||||
/// <param name="itemHandle">The item handle to remove.</param>
|
|
||||||
public void RemoveItem(int handle, int itemHandle)
|
|
||||||
{
|
|
||||||
_lmxProxy!.RemoveItem(handle, itemHandle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Enables supervisory callbacks for the specified runtime item.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="handle">The runtime connection handle.</param>
|
|
||||||
/// <param name="itemHandle">The item handle to monitor.</param>
|
|
||||||
public void AdviseSupervisory(int handle, int itemHandle)
|
|
||||||
{
|
|
||||||
_lmxProxy!.AdviseSupervisory(handle, itemHandle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Disables supervisory callbacks for the specified runtime item.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="handle">The runtime connection handle.</param>
|
|
||||||
/// <param name="itemHandle">The item handle to stop monitoring.</param>
|
|
||||||
public void UnAdviseSupervisory(int handle, int itemHandle)
|
|
||||||
{
|
|
||||||
_lmxProxy!.UnAdvise(handle, itemHandle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes a value to the specified runtime item through the COM proxy.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="handle">The runtime connection handle.</param>
|
|
||||||
/// <param name="itemHandle">The item handle to write.</param>
|
|
||||||
/// <param name="value">The value to send to the runtime.</param>
|
|
||||||
/// <param name="securityClassification">The Wonderware security classification applied to the write.</param>
|
|
||||||
public void Write(int handle, int itemHandle, object value, int securityClassification)
|
|
||||||
{
|
|
||||||
_lmxProxy!.Write(handle, itemHandle, value, securityClassification);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ProxyOnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue,
|
|
||||||
int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] ItemStatus)
|
|
||||||
{
|
|
||||||
OnDataChange?.Invoke(hLMXServerHandle, phItemHandle, pvItemValue, pwItemQuality, pftItemTimeStamp,
|
|
||||||
ref ItemStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ProxyOnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus)
|
|
||||||
{
|
|
||||||
OnWriteComplete?.Invoke(hLMXServerHandle, phItemHandle, ref ItemStatus);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.MxAccess
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Dedicated STA thread with a raw Win32 message pump for COM interop.
|
|
||||||
/// All MxAccess COM objects must be created and called on this thread. (MXA-001)
|
|
||||||
/// </summary>
|
|
||||||
public sealed class StaComThread : IDisposable
|
|
||||||
{
|
|
||||||
private const uint WM_APP = 0x8000;
|
|
||||||
private const uint PM_NOREMOVE = 0x0000;
|
|
||||||
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<StaComThread>();
|
|
||||||
private static readonly TimeSpan PumpLogInterval = TimeSpan.FromMinutes(5);
|
|
||||||
private readonly TaskCompletionSource<bool> _ready = new();
|
|
||||||
|
|
||||||
private readonly Thread _thread;
|
|
||||||
private readonly ConcurrentQueue<WorkItem> _workItems = new();
|
|
||||||
private long _appMessages;
|
|
||||||
private long _dispatchedMessages;
|
|
||||||
private bool _disposed;
|
|
||||||
private DateTime _lastLogTime;
|
|
||||||
private volatile uint _nativeThreadId;
|
|
||||||
private volatile bool _pumpExited;
|
|
||||||
|
|
||||||
private long _totalMessages;
|
|
||||||
private long _workItemsExecuted;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a dedicated STA thread wrapper for Wonderware COM interop.
|
|
||||||
/// </summary>
|
|
||||||
public StaComThread()
|
|
||||||
{
|
|
||||||
_thread = new Thread(ThreadEntry)
|
|
||||||
{
|
|
||||||
Name = "MxAccess-STA",
|
|
||||||
IsBackground = true
|
|
||||||
};
|
|
||||||
_thread.SetApartmentState(ApartmentState.STA);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a value indicating whether the STA thread is running and able to accept work.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsRunning => _nativeThreadId != 0 && !_disposed && !_pumpExited;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops the STA thread and releases the message-pump resources used for COM interop.
|
|
||||||
/// </summary>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (_disposed) return;
|
|
||||||
_disposed = true;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_nativeThreadId != 0 && !_pumpExited)
|
|
||||||
PostThreadMessage(_nativeThreadId, WM_APP + 1, IntPtr.Zero, IntPtr.Zero);
|
|
||||||
_thread.Join(TimeSpan.FromSeconds(5));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Error shutting down STA COM thread");
|
|
||||||
}
|
|
||||||
|
|
||||||
DrainAndFaultQueue();
|
|
||||||
Log.Information("STA COM thread stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Starts the STA thread and waits until its message pump is ready for COM work.
|
|
||||||
/// </summary>
|
|
||||||
public void Start()
|
|
||||||
{
|
|
||||||
_thread.Start();
|
|
||||||
_ready.Task.GetAwaiter().GetResult();
|
|
||||||
Log.Information("STA COM thread started (ThreadId={ThreadId})", _thread.ManagedThreadId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Queues an action to execute on the STA thread.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="action">The work item to execute on the STA thread.</param>
|
|
||||||
/// <returns>A task that completes when the action has finished executing.</returns>
|
|
||||||
public Task RunAsync(Action action)
|
|
||||||
{
|
|
||||||
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
|
|
||||||
if (_pumpExited) throw new InvalidOperationException("STA COM thread pump has exited");
|
|
||||||
|
|
||||||
var tcs = new TaskCompletionSource<bool>();
|
|
||||||
_workItems.Enqueue(new WorkItem
|
|
||||||
{
|
|
||||||
Execute = () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
action();
|
|
||||||
tcs.TrySetResult(true);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
tcs.TrySetException(ex);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Fault = ex => tcs.TrySetException(ex)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero))
|
|
||||||
{
|
|
||||||
_pumpExited = true;
|
|
||||||
DrainAndFaultQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
return tcs.Task;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Queues a function to execute on the STA thread and returns its result.
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T">The result type produced by the function.</typeparam>
|
|
||||||
/// <param name="func">The work item to execute on the STA thread.</param>
|
|
||||||
/// <returns>A task that completes with the function result.</returns>
|
|
||||||
public Task<T> RunAsync<T>(Func<T> func)
|
|
||||||
{
|
|
||||||
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
|
|
||||||
if (_pumpExited) throw new InvalidOperationException("STA COM thread pump has exited");
|
|
||||||
|
|
||||||
var tcs = new TaskCompletionSource<T>();
|
|
||||||
_workItems.Enqueue(new WorkItem
|
|
||||||
{
|
|
||||||
Execute = () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
tcs.TrySetResult(func());
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
tcs.TrySetException(ex);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Fault = ex => tcs.TrySetException(ex)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero))
|
|
||||||
{
|
|
||||||
_pumpExited = true;
|
|
||||||
DrainAndFaultQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
return tcs.Task;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ThreadEntry()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_nativeThreadId = GetCurrentThreadId();
|
|
||||||
|
|
||||||
MSG msg;
|
|
||||||
PeekMessage(out msg, IntPtr.Zero, 0, 0, PM_NOREMOVE);
|
|
||||||
|
|
||||||
_ready.TrySetResult(true);
|
|
||||||
_lastLogTime = DateTime.UtcNow;
|
|
||||||
|
|
||||||
Log.Debug("STA message pump entering loop");
|
|
||||||
|
|
||||||
while (GetMessage(out msg, IntPtr.Zero, 0, 0) > 0)
|
|
||||||
{
|
|
||||||
_totalMessages++;
|
|
||||||
|
|
||||||
if (msg.message == WM_APP)
|
|
||||||
{
|
|
||||||
_appMessages++;
|
|
||||||
DrainQueue();
|
|
||||||
}
|
|
||||||
else if (msg.message == WM_APP + 1)
|
|
||||||
{
|
|
||||||
DrainQueue();
|
|
||||||
PostQuitMessage(0);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_dispatchedMessages++;
|
|
||||||
TranslateMessage(ref msg);
|
|
||||||
DispatchMessage(ref msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
LogPumpStatsIfDue();
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Information(
|
|
||||||
"STA message pump exited (Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems})",
|
|
||||||
_totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "STA COM thread crashed");
|
|
||||||
_ready.TrySetException(ex);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_pumpExited = true;
|
|
||||||
DrainAndFaultQueue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrainQueue()
|
|
||||||
{
|
|
||||||
while (_workItems.TryDequeue(out var workItem))
|
|
||||||
{
|
|
||||||
_workItemsExecuted++;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
workItem.Execute();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Unhandled exception in STA work item");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrainAndFaultQueue()
|
|
||||||
{
|
|
||||||
var faultException = new InvalidOperationException("STA COM thread pump has exited");
|
|
||||||
while (_workItems.TryDequeue(out var workItem))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
workItem.Fault(faultException);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Faulting a TCS should not throw, but guard against it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void LogPumpStatsIfDue()
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
if (now - _lastLogTime < PumpLogInterval) return;
|
|
||||||
Log.Debug(
|
|
||||||
"STA pump alive: Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems}, Pending={Pending}",
|
|
||||||
_totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted, _workItems.Count);
|
|
||||||
_lastLogTime = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class WorkItem
|
|
||||||
{
|
|
||||||
public Action Execute { get; set; }
|
|
||||||
public Action<Exception> Fault { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Win32 PInvoke
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
private struct MSG
|
|
||||||
{
|
|
||||||
public IntPtr hwnd;
|
|
||||||
public uint message;
|
|
||||||
public IntPtr wParam;
|
|
||||||
public IntPtr lParam;
|
|
||||||
public uint time;
|
|
||||||
public POINT pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
private struct POINT
|
|
||||||
{
|
|
||||||
public int x;
|
|
||||||
public int y;
|
|
||||||
}
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool TranslateMessage(ref MSG lpMsg);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern IntPtr DispatchMessage(ref MSG lpMsg);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool PostThreadMessage(uint idThread, uint Msg, IntPtr wParam, IntPtr lParam);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern void PostQuitMessage(int nExitCode);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax,
|
|
||||||
uint wRemoveMsg);
|
|
||||||
|
|
||||||
[DllImport("kernel32.dll")]
|
|
||||||
private static extern uint GetCurrentThreadId();
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the tag reference mappings from Galaxy hierarchy and attributes.
|
|
||||||
/// Testable without an OPC UA server. (OPC-002, OPC-003, OPC-004)
|
|
||||||
/// </summary>
|
|
||||||
public class AddressSpaceBuilder
|
|
||||||
{
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<AddressSpaceBuilder>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds an in-memory model of the Galaxy hierarchy and attribute mappings before the OPC UA server materializes
|
|
||||||
/// nodes.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="hierarchy">The Galaxy object hierarchy returned by the repository.</param>
|
|
||||||
/// <param name="attributes">The Galaxy attribute rows associated with the hierarchy.</param>
|
|
||||||
/// <returns>An address-space model containing roots, variables, and tag-reference mappings.</returns>
|
|
||||||
public static AddressSpaceModel Build(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
|
|
||||||
{
|
|
||||||
var model = new AddressSpaceModel();
|
|
||||||
var objectMap = hierarchy.ToDictionary(h => h.GobjectId);
|
|
||||||
|
|
||||||
var attrsByObject = attributes
|
|
||||||
.GroupBy(a => a.GobjectId)
|
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
|
||||||
|
|
||||||
// Build parent→children map
|
|
||||||
var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)
|
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
|
||||||
|
|
||||||
// Find root objects (parent not in hierarchy)
|
|
||||||
var knownIds = new HashSet<int>(hierarchy.Select(h => h.GobjectId));
|
|
||||||
|
|
||||||
foreach (var obj in hierarchy)
|
|
||||||
{
|
|
||||||
var nodeInfo = BuildNodeInfo(obj, attrsByObject, childrenByParent, model);
|
|
||||||
|
|
||||||
if (!knownIds.Contains(obj.ParentGobjectId))
|
|
||||||
model.RootNodes.Add(nodeInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Information("Address space model: {Objects} objects, {Variables} variables, {Mappings} tag refs",
|
|
||||||
model.ObjectCount, model.VariableCount, model.NodeIdToTagReference.Count);
|
|
||||||
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static NodeInfo BuildNodeInfo(GalaxyObjectInfo obj,
|
|
||||||
Dictionary<int, List<GalaxyAttributeInfo>> attrsByObject,
|
|
||||||
Dictionary<int, List<GalaxyObjectInfo>> childrenByParent,
|
|
||||||
AddressSpaceModel model)
|
|
||||||
{
|
|
||||||
var node = new NodeInfo
|
|
||||||
{
|
|
||||||
GobjectId = obj.GobjectId,
|
|
||||||
TagName = obj.TagName,
|
|
||||||
BrowseName = obj.BrowseName,
|
|
||||||
ParentGobjectId = obj.ParentGobjectId,
|
|
||||||
IsArea = obj.IsArea
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!obj.IsArea)
|
|
||||||
model.ObjectCount++;
|
|
||||||
|
|
||||||
if (attrsByObject.TryGetValue(obj.GobjectId, out var attrs))
|
|
||||||
foreach (var attr in attrs)
|
|
||||||
{
|
|
||||||
node.Attributes.Add(new AttributeNodeInfo
|
|
||||||
{
|
|
||||||
AttributeName = attr.AttributeName,
|
|
||||||
FullTagReference = attr.FullTagReference,
|
|
||||||
MxDataType = attr.MxDataType,
|
|
||||||
IsArray = attr.IsArray,
|
|
||||||
ArrayDimension = attr.ArrayDimension,
|
|
||||||
PrimitiveName = attr.PrimitiveName ?? "",
|
|
||||||
SecurityClassification = attr.SecurityClassification,
|
|
||||||
IsHistorized = attr.IsHistorized,
|
|
||||||
IsAlarm = attr.IsAlarm
|
|
||||||
});
|
|
||||||
|
|
||||||
model.NodeIdToTagReference[GetNodeIdentifier(attr)] = attr.FullTagReference;
|
|
||||||
model.VariableCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetNodeIdentifier(GalaxyAttributeInfo attr)
|
|
||||||
{
|
|
||||||
if (!attr.IsArray)
|
|
||||||
return attr.FullTagReference;
|
|
||||||
|
|
||||||
return attr.FullTagReference.EndsWith("[]", StringComparison.Ordinal)
|
|
||||||
? attr.FullTagReference.Substring(0, attr.FullTagReference.Length - 2)
|
|
||||||
: attr.FullTagReference;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Node info for the address space tree.
|
|
||||||
/// </summary>
|
|
||||||
public class NodeInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy object identifier represented by this address-space node.
|
|
||||||
/// </summary>
|
|
||||||
public int GobjectId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the runtime tag name used to tie the node back to Galaxy metadata.
|
|
||||||
/// </summary>
|
|
||||||
public string TagName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the browse name exposed to OPC UA clients for this hierarchy node.
|
|
||||||
/// </summary>
|
|
||||||
public string BrowseName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the parent Galaxy object identifier used to assemble the tree.
|
|
||||||
/// </summary>
|
|
||||||
public int ParentGobjectId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the node represents a Galaxy area folder.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsArea { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the attribute nodes published beneath this object.
|
|
||||||
/// </summary>
|
|
||||||
public List<AttributeNodeInfo> Attributes { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the child nodes that appear under this branch of the Galaxy hierarchy.
|
|
||||||
/// </summary>
|
|
||||||
public List<NodeInfo> Children { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lightweight description of an attribute node that will become an OPC UA variable.
|
|
||||||
/// </summary>
|
|
||||||
public class AttributeNodeInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy attribute name published under the object.
|
|
||||||
/// </summary>
|
|
||||||
public string AttributeName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the fully qualified runtime reference used for reads, writes, and subscriptions.
|
|
||||||
/// </summary>
|
|
||||||
public string FullTagReference { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy data type code used to pick the OPC UA variable type.
|
|
||||||
/// </summary>
|
|
||||||
public int MxDataType { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the attribute is modeled as an array.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsArray { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the declared array length when the attribute is a fixed-size array.
|
|
||||||
/// </summary>
|
|
||||||
public int? ArrayDimension { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the primitive name that groups the attribute under a sub-object node.
|
|
||||||
/// Empty for root-level attributes.
|
|
||||||
/// </summary>
|
|
||||||
public string PrimitiveName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy security classification that determines OPC UA write access.
|
|
||||||
/// </summary>
|
|
||||||
public int SecurityClassification { get; set; } = 1;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the attribute is historized.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsHistorized { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the attribute is an alarm.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsAlarm { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Result of building the address space model.
|
|
||||||
/// </summary>
|
|
||||||
public class AddressSpaceModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the root nodes that become the top-level browse entries in the Galaxy namespace.
|
|
||||||
/// </summary>
|
|
||||||
public List<NodeInfo> RootNodes { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the mapping from OPC UA node identifiers to runtime tag references.
|
|
||||||
/// </summary>
|
|
||||||
public Dictionary<string, string> NodeIdToTagReference { get; set; } =
|
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of non-area Galaxy objects included in the model.
|
|
||||||
/// </summary>
|
|
||||||
public int ObjectCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of variable nodes created from Galaxy attributes.
|
|
||||||
/// </summary>
|
|
||||||
public int VariableCount { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Computes the set of changed Galaxy object IDs between two snapshots of hierarchy and attributes.
|
|
||||||
/// </summary>
|
|
||||||
public static class AddressSpaceDiff
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Compares old and new hierarchy+attributes and returns the set of gobject IDs that have any difference.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="oldHierarchy">The previously published Galaxy object hierarchy snapshot.</param>
|
|
||||||
/// <param name="oldAttributes">The previously published Galaxy attribute snapshot keyed to the old hierarchy.</param>
|
|
||||||
/// <param name="newHierarchy">The latest Galaxy object hierarchy snapshot pulled from the repository.</param>
|
|
||||||
/// <param name="newAttributes">The latest Galaxy attribute snapshot that should be reflected in the OPC UA namespace.</param>
|
|
||||||
public static HashSet<int> FindChangedGobjectIds(
|
|
||||||
List<GalaxyObjectInfo> oldHierarchy, List<GalaxyAttributeInfo> oldAttributes,
|
|
||||||
List<GalaxyObjectInfo> newHierarchy, List<GalaxyAttributeInfo> newAttributes)
|
|
||||||
{
|
|
||||||
var changed = new HashSet<int>();
|
|
||||||
|
|
||||||
var oldObjects = oldHierarchy.ToDictionary(h => h.GobjectId);
|
|
||||||
var newObjects = newHierarchy.ToDictionary(h => h.GobjectId);
|
|
||||||
|
|
||||||
// Added objects
|
|
||||||
foreach (var id in newObjects.Keys)
|
|
||||||
if (!oldObjects.ContainsKey(id))
|
|
||||||
changed.Add(id);
|
|
||||||
|
|
||||||
// Removed objects
|
|
||||||
foreach (var id in oldObjects.Keys)
|
|
||||||
if (!newObjects.ContainsKey(id))
|
|
||||||
changed.Add(id);
|
|
||||||
|
|
||||||
// Modified objects
|
|
||||||
foreach (var kvp in newObjects)
|
|
||||||
if (oldObjects.TryGetValue(kvp.Key, out var oldObj) && !ObjectsEqual(oldObj, kvp.Value))
|
|
||||||
changed.Add(kvp.Key);
|
|
||||||
|
|
||||||
// Attribute changes — group by gobject_id and compare
|
|
||||||
var oldAttrsByObj = oldAttributes.GroupBy(a => a.GobjectId)
|
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
|
||||||
var newAttrsByObj = newAttributes.GroupBy(a => a.GobjectId)
|
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
|
||||||
|
|
||||||
// All gobject_ids that have attributes in either old or new
|
|
||||||
var allAttrGobjectIds = new HashSet<int>(oldAttrsByObj.Keys);
|
|
||||||
allAttrGobjectIds.UnionWith(newAttrsByObj.Keys);
|
|
||||||
|
|
||||||
foreach (var id in allAttrGobjectIds)
|
|
||||||
{
|
|
||||||
if (changed.Contains(id))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
oldAttrsByObj.TryGetValue(id, out var oldAttrs);
|
|
||||||
newAttrsByObj.TryGetValue(id, out var newAttrs);
|
|
||||||
|
|
||||||
if (!AttributeSetsEqual(oldAttrs, newAttrs))
|
|
||||||
changed.Add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Expands a set of changed gobject IDs to include all descendant gobject IDs in the hierarchy.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="changed">The root Galaxy objects that were detected as changed between snapshots.</param>
|
|
||||||
/// <param name="hierarchy">The hierarchy used to include descendant objects whose OPC UA nodes must also be rebuilt.</param>
|
|
||||||
public static HashSet<int> ExpandToSubtrees(HashSet<int> changed, List<GalaxyObjectInfo> hierarchy)
|
|
||||||
{
|
|
||||||
var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)
|
|
||||||
.ToDictionary(g => g.Key, g => g.Select(h => h.GobjectId).ToList());
|
|
||||||
|
|
||||||
var expanded = new HashSet<int>(changed);
|
|
||||||
var queue = new Queue<int>(changed);
|
|
||||||
|
|
||||||
while (queue.Count > 0)
|
|
||||||
{
|
|
||||||
var id = queue.Dequeue();
|
|
||||||
if (childrenByParent.TryGetValue(id, out var children))
|
|
||||||
foreach (var childId in children)
|
|
||||||
if (expanded.Add(childId))
|
|
||||||
queue.Enqueue(childId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return expanded;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool ObjectsEqual(GalaxyObjectInfo a, GalaxyObjectInfo b)
|
|
||||||
{
|
|
||||||
return a.TagName == b.TagName
|
|
||||||
&& a.BrowseName == b.BrowseName
|
|
||||||
&& a.ContainedName == b.ContainedName
|
|
||||||
&& a.ParentGobjectId == b.ParentGobjectId
|
|
||||||
&& a.IsArea == b.IsArea;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool AttributeSetsEqual(List<GalaxyAttributeInfo>? a, List<GalaxyAttributeInfo>? b)
|
|
||||||
{
|
|
||||||
if (a == null && b == null) return true;
|
|
||||||
if (a == null || b == null) return false;
|
|
||||||
if (a.Count != b.Count) return false;
|
|
||||||
|
|
||||||
// Sort by a stable key and compare pairwise
|
|
||||||
var sortedA = a.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList();
|
|
||||||
var sortedB = b.OrderBy(x => x.FullTagReference).ThenBy(x => x.PrimitiveName).ToList();
|
|
||||||
|
|
||||||
for (var i = 0; i < sortedA.Count; i++)
|
|
||||||
if (!AttributesEqual(sortedA[i], sortedB[i]))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool AttributesEqual(GalaxyAttributeInfo a, GalaxyAttributeInfo b)
|
|
||||||
{
|
|
||||||
return a.AttributeName == b.AttributeName
|
|
||||||
&& a.FullTagReference == b.FullTagReference
|
|
||||||
&& a.MxDataType == b.MxDataType
|
|
||||||
&& a.IsArray == b.IsArray
|
|
||||||
&& a.ArrayDimension == b.ArrayDimension
|
|
||||||
&& a.PrimitiveName == b.PrimitiveName
|
|
||||||
&& a.SecurityClassification == b.SecurityClassification
|
|
||||||
&& a.IsHistorized == b.IsHistorized
|
|
||||||
&& a.IsAlarm == b.IsAlarm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Opc.Ua;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Converts between domain Vtq and OPC UA DataValue. Handles all data_type_mapping.md types. (OPC-005, OPC-007)
|
|
||||||
/// </summary>
|
|
||||||
public static class DataValueConverter
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Converts a bridge VTQ snapshot into an OPC UA data value.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="vtq">The VTQ snapshot to convert.</param>
|
|
||||||
/// <returns>An OPC UA data value suitable for reads and subscriptions.</returns>
|
|
||||||
public static DataValue FromVtq(Vtq vtq)
|
|
||||||
{
|
|
||||||
var statusCode = new StatusCode(QualityMapper.MapToOpcUaStatusCode(vtq.Quality));
|
|
||||||
|
|
||||||
var dataValue = new DataValue
|
|
||||||
{
|
|
||||||
Value = ConvertToOpcUaValue(vtq.Value),
|
|
||||||
StatusCode = statusCode,
|
|
||||||
SourceTimestamp = vtq.Timestamp.Kind == DateTimeKind.Utc
|
|
||||||
? vtq.Timestamp
|
|
||||||
: vtq.Timestamp.ToUniversalTime(),
|
|
||||||
ServerTimestamp = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
return dataValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Converts an OPC UA data value back into a bridge VTQ snapshot.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="dataValue">The OPC UA data value to convert.</param>
|
|
||||||
/// <returns>A VTQ snapshot containing the converted value, timestamp, and derived quality.</returns>
|
|
||||||
public static Vtq ToVtq(DataValue dataValue)
|
|
||||||
{
|
|
||||||
var quality = MapStatusCodeToQuality(dataValue.StatusCode);
|
|
||||||
var timestamp = dataValue.SourceTimestamp != DateTime.MinValue
|
|
||||||
? dataValue.SourceTimestamp
|
|
||||||
: DateTime.UtcNow;
|
|
||||||
|
|
||||||
return new Vtq(dataValue.Value, timestamp, quality);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static object? ConvertToOpcUaValue(object? value)
|
|
||||||
{
|
|
||||||
if (value == null) return null;
|
|
||||||
|
|
||||||
return value switch
|
|
||||||
{
|
|
||||||
bool _ => value,
|
|
||||||
int _ => value,
|
|
||||||
float _ => value,
|
|
||||||
double _ => value,
|
|
||||||
string _ => value,
|
|
||||||
DateTime dt => dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime(),
|
|
||||||
TimeSpan ts => ts.TotalSeconds, // ElapsedTime → Double seconds
|
|
||||||
short s => (int)s,
|
|
||||||
long l => l,
|
|
||||||
byte b => (int)b,
|
|
||||||
bool[] _ => value,
|
|
||||||
int[] _ => value,
|
|
||||||
float[] _ => value,
|
|
||||||
double[] _ => value,
|
|
||||||
string[] _ => value,
|
|
||||||
DateTime[] _ => value,
|
|
||||||
_ => value.ToString()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Quality MapStatusCodeToQuality(StatusCode statusCode)
|
|
||||||
{
|
|
||||||
var code = statusCode.Code;
|
|
||||||
if (StatusCode.IsGood(statusCode)) return Quality.Good;
|
|
||||||
if (StatusCode.IsUncertain(statusCode)) return Quality.Uncertain;
|
|
||||||
|
|
||||||
return code switch
|
|
||||||
{
|
|
||||||
StatusCodes.BadNotConnected => Quality.BadNotConnected,
|
|
||||||
StatusCodes.BadCommunicationError => Quality.BadCommFailure,
|
|
||||||
StatusCodes.BadConfigurationError => Quality.BadConfigError,
|
|
||||||
StatusCodes.BadOutOfService => Quality.BadOutOfService,
|
|
||||||
StatusCodes.BadWaitingForInitialData => Quality.BadWaitingForInitialData,
|
|
||||||
_ => Quality.Bad
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,528 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Opc.Ua;
|
|
||||||
using Opc.Ua.Server;
|
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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
|
|
||||||
{
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<LmxOpcUaServer>();
|
|
||||||
private readonly bool _alarmTrackingEnabled;
|
|
||||||
private readonly AlarmObjectFilter? _alarmObjectFilter;
|
|
||||||
private readonly string? _applicationUri;
|
|
||||||
private readonly AuthenticationConfiguration _authConfig;
|
|
||||||
private readonly IUserAuthenticationProvider? _authProvider;
|
|
||||||
|
|
||||||
private readonly string _galaxyName;
|
|
||||||
private readonly IHistorianDataSource? _historianDataSource;
|
|
||||||
private readonly PerformanceMetrics _metrics;
|
|
||||||
private readonly IMxAccessClient _mxAccessClient;
|
|
||||||
private readonly RedundancyConfiguration _redundancyConfig;
|
|
||||||
private readonly ServiceLevelCalculator _serviceLevelCalculator = new();
|
|
||||||
private NodeId? _alarmAckRoleId;
|
|
||||||
|
|
||||||
// Resolved custom role NodeIds (populated in CreateMasterNodeManager)
|
|
||||||
private NodeId? _readOnlyRoleId;
|
|
||||||
private NodeId? _writeConfigureRoleId;
|
|
||||||
private NodeId? _writeOperateRoleId;
|
|
||||||
private NodeId? _writeTuneRoleId;
|
|
||||||
|
|
||||||
private readonly bool _runtimeStatusProbesEnabled;
|
|
||||||
private readonly int _runtimeStatusUnknownTimeoutSeconds;
|
|
||||||
private readonly int _mxAccessRequestTimeoutSeconds;
|
|
||||||
private readonly int _historianRequestTimeoutSeconds;
|
|
||||||
|
|
||||||
public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
|
|
||||||
IHistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false,
|
|
||||||
AuthenticationConfiguration? authConfig = null, IUserAuthenticationProvider? authProvider = null,
|
|
||||||
RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null,
|
|
||||||
AlarmObjectFilter? alarmObjectFilter = null,
|
|
||||||
bool runtimeStatusProbesEnabled = false,
|
|
||||||
int runtimeStatusUnknownTimeoutSeconds = 15,
|
|
||||||
int mxAccessRequestTimeoutSeconds = 30,
|
|
||||||
int historianRequestTimeoutSeconds = 60)
|
|
||||||
{
|
|
||||||
_galaxyName = galaxyName;
|
|
||||||
_mxAccessClient = mxAccessClient;
|
|
||||||
_metrics = metrics;
|
|
||||||
_historianDataSource = historianDataSource;
|
|
||||||
_alarmTrackingEnabled = alarmTrackingEnabled;
|
|
||||||
_alarmObjectFilter = alarmObjectFilter;
|
|
||||||
_authConfig = authConfig ?? new AuthenticationConfiguration();
|
|
||||||
_authProvider = authProvider;
|
|
||||||
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
|
|
||||||
_applicationUri = applicationUri;
|
|
||||||
_runtimeStatusProbesEnabled = runtimeStatusProbesEnabled;
|
|
||||||
_runtimeStatusUnknownTimeoutSeconds = runtimeStatusUnknownTimeoutSeconds;
|
|
||||||
_mxAccessRequestTimeoutSeconds = mxAccessRequestTimeoutSeconds;
|
|
||||||
_historianRequestTimeoutSeconds = historianRequestTimeoutSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the custom node manager that publishes the Galaxy-backed namespace.
|
|
||||||
/// </summary>
|
|
||||||
public LmxNodeManager? NodeManager { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of active OPC UA sessions currently connected to the server.
|
|
||||||
/// </summary>
|
|
||||||
public int ActiveSessionCount
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return ServerInternal?.SessionManager?.GetSessions()?.Count ?? 0;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server,
|
|
||||||
ApplicationConfiguration configuration)
|
|
||||||
{
|
|
||||||
// Resolve custom role NodeIds from the roles namespace
|
|
||||||
ResolveRoleNodeIds(server);
|
|
||||||
|
|
||||||
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
|
|
||||||
NodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics,
|
|
||||||
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite,
|
|
||||||
_writeOperateRoleId, _writeTuneRoleId, _writeConfigureRoleId, _alarmAckRoleId,
|
|
||||||
_alarmObjectFilter,
|
|
||||||
_runtimeStatusProbesEnabled, _runtimeStatusUnknownTimeoutSeconds,
|
|
||||||
_mxAccessRequestTimeoutSeconds, _historianRequestTimeoutSeconds);
|
|
||||||
|
|
||||||
var nodeManagers = new List<INodeManager> { NodeManager };
|
|
||||||
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ResolveRoleNodeIds(IServerInternal server)
|
|
||||||
{
|
|
||||||
var nsIndex = server.NamespaceUris.GetIndexOrAppend(LmxRoleIds.NamespaceUri);
|
|
||||||
_readOnlyRoleId = new NodeId(LmxRoleIds.ReadOnly, nsIndex);
|
|
||||||
_writeOperateRoleId = new NodeId(LmxRoleIds.WriteOperate, nsIndex);
|
|
||||||
_writeTuneRoleId = new NodeId(LmxRoleIds.WriteTune, nsIndex);
|
|
||||||
_writeConfigureRoleId = new NodeId(LmxRoleIds.WriteConfigure, nsIndex);
|
|
||||||
_alarmAckRoleId = new NodeId(LmxRoleIds.AlarmAck, nsIndex);
|
|
||||||
Log.Debug("Resolved custom role NodeIds in namespace index {NsIndex}", nsIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void OnServerStarted(IServerInternal server)
|
|
||||||
{
|
|
||||||
base.OnServerStarted(server);
|
|
||||||
server.SessionManager.ImpersonateUser += OnImpersonateUser;
|
|
||||||
|
|
||||||
ConfigureRedundancy(server);
|
|
||||||
ConfigureHistoryCapabilities(server);
|
|
||||||
ConfigureServerCapabilities(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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ConfigureHistoryCapabilities(IServerInternal server)
|
|
||||||
{
|
|
||||||
if (_historianDataSource == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var dnm = server.DiagnosticsNodeManager;
|
|
||||||
var ctx = server.DefaultSystemContext;
|
|
||||||
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.HistoryServerCapabilities_AccessHistoryDataCapability, true);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.HistoryServerCapabilities_AccessHistoryEventsCapability,
|
|
||||||
_alarmTrackingEnabled);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.HistoryServerCapabilities_MaxReturnDataValues,
|
|
||||||
(uint)(_historianDataSource != null ? 10000 : 0));
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.HistoryServerCapabilities_MaxReturnEventValues, (uint)0);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.HistoryServerCapabilities_InsertDataCapability, false);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.HistoryServerCapabilities_ReplaceDataCapability, false);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.HistoryServerCapabilities_UpdateDataCapability, false);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.HistoryServerCapabilities_DeleteRawCapability, false);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.HistoryServerCapabilities_DeleteAtTimeCapability, false);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.HistoryServerCapabilities_InsertEventCapability, false);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.HistoryServerCapabilities_ReplaceEventCapability, false);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.HistoryServerCapabilities_UpdateEventCapability, false);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.HistoryServerCapabilities_DeleteEventCapability, false);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.HistoryServerCapabilities_InsertAnnotationCapability, false);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.HistoryServerCapabilities_ServerTimestampSupported, true);
|
|
||||||
|
|
||||||
// Add aggregate function references under the AggregateFunctions folder
|
|
||||||
var aggFolderNode = dnm?.FindPredefinedNode(
|
|
||||||
ObjectIds.HistoryServerCapabilities_AggregateFunctions,
|
|
||||||
typeof(FolderState)) as FolderState;
|
|
||||||
|
|
||||||
if (aggFolderNode != null)
|
|
||||||
{
|
|
||||||
var aggregateIds = new[]
|
|
||||||
{
|
|
||||||
ObjectIds.AggregateFunction_Average,
|
|
||||||
ObjectIds.AggregateFunction_Minimum,
|
|
||||||
ObjectIds.AggregateFunction_Maximum,
|
|
||||||
ObjectIds.AggregateFunction_Count,
|
|
||||||
ObjectIds.AggregateFunction_Start,
|
|
||||||
ObjectIds.AggregateFunction_End,
|
|
||||||
ObjectIds.AggregateFunction_StandardDeviationPopulation
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (var aggId in aggregateIds)
|
|
||||||
{
|
|
||||||
var aggNode = dnm?.FindPredefinedNode(aggId, typeof(BaseObjectState)) as BaseObjectState;
|
|
||||||
if (aggNode != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
aggFolderNode.AddReference(ReferenceTypeIds.Organizes, false, aggNode.NodeId);
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
// Reference already exists — skip
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
aggNode.AddReference(ReferenceTypeIds.Organizes, true, aggFolderNode.NodeId);
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
// Reference already exists — skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Information("HistoryServerCapabilities configured with {Count} aggregate functions",
|
|
||||||
aggregateIds.Length);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Log.Warning("AggregateFunctions folder not found in predefined nodes");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex,
|
|
||||||
"Failed to configure HistoryServerCapabilities — history discovery may not work for clients");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ConfigureServerCapabilities(IServerInternal server)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var dnm = server.DiagnosticsNodeManager;
|
|
||||||
var ctx = server.DefaultSystemContext;
|
|
||||||
|
|
||||||
// Server profiles
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_ServerProfileArray,
|
|
||||||
new[] { "http://opcfoundation.org/UA-Profile/Server/StandardUA2017" });
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_LocaleIdArray,
|
|
||||||
new[] { "en" });
|
|
||||||
|
|
||||||
// Limits
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_MinSupportedSampleRate, 100.0);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_MaxBrowseContinuationPoints, (ushort)100);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_MaxQueryContinuationPoints, (ushort)0);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_MaxHistoryContinuationPoints, (ushort)100);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_MaxArrayLength, (uint)65535);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_MaxStringLength, (uint)(4 * 1024 * 1024));
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_MaxByteStringLength, (uint)(4 * 1024 * 1024));
|
|
||||||
|
|
||||||
// OperationLimits
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRead, (uint)1000);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerWrite, (uint)1000);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerBrowse, (uint)1000);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRegisterNodes, (uint)1000);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerTranslateBrowsePathsToNodeIds,
|
|
||||||
(uint)1000);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerMethodCall, (uint)0);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerNodeManagement, (uint)0);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxMonitoredItemsPerCall, (uint)1000);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadData, (uint)1000);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryReadEvents, (uint)1000);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateData, (uint)0);
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerHistoryUpdateEvents, (uint)0);
|
|
||||||
|
|
||||||
// Diagnostics
|
|
||||||
SetPredefinedVariable(dnm, ctx,
|
|
||||||
VariableIds.Server_ServerDiagnostics_EnabledFlag, true);
|
|
||||||
|
|
||||||
Log.Information(
|
|
||||||
"ServerCapabilities configured (OperationLimits, diagnostics enabled)");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex,
|
|
||||||
"Failed to configure ServerCapabilities — capability discovery may not work for clients");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SetPredefinedVariable(DiagnosticsNodeManager? dnm, ServerSystemContext ctx,
|
|
||||||
NodeId variableId, object value)
|
|
||||||
{
|
|
||||||
var node = dnm?.FindPredefinedNode(variableId, typeof(BaseVariableState)) as BaseVariableState;
|
|
||||||
if (node != null)
|
|
||||||
{
|
|
||||||
node.Value = value;
|
|
||||||
node.ClearChangeMasks(ctx, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the server's ServiceLevel based on current runtime health.
|
|
||||||
/// Called by the service layer when MXAccess or DB health changes.
|
|
||||||
/// </summary>
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
|
|
||||||
{
|
|
||||||
if (!_authConfig.AllowAnonymous)
|
|
||||||
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected,
|
|
||||||
"Anonymous access is disabled");
|
|
||||||
|
|
||||||
args.Identity = new RoleBasedIdentity(
|
|
||||||
new UserIdentity(anonymousToken),
|
|
||||||
new List<Role> { Role.Anonymous });
|
|
||||||
Log.Debug("Anonymous session accepted (canWrite={CanWrite})", _authConfig.AnonymousCanWrite);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.NewIdentity is UserNameIdentityToken userNameToken)
|
|
||||||
{
|
|
||||||
var password = userNameToken.DecryptedPassword ?? "";
|
|
||||||
|
|
||||||
if (_authProvider == null || !_authProvider.ValidateCredentials(userNameToken.UserName, password))
|
|
||||||
{
|
|
||||||
Log.Warning("AUDIT: Authentication FAILED for user {Username} from session {SessionId}",
|
|
||||||
userNameToken.UserName, session?.Id);
|
|
||||||
throw new ServiceResultException(StatusCodes.BadUserAccessDenied, "Invalid username or password");
|
|
||||||
}
|
|
||||||
|
|
||||||
var roles = new List<Role> { Role.AuthenticatedUser };
|
|
||||||
|
|
||||||
if (_authProvider is IRoleProvider roleProvider)
|
|
||||||
{
|
|
||||||
var appRoles = roleProvider.GetUserRoles(userNameToken.UserName);
|
|
||||||
|
|
||||||
foreach (var appRole in appRoles)
|
|
||||||
switch (appRole)
|
|
||||||
{
|
|
||||||
case AppRoles.ReadOnly:
|
|
||||||
if (_readOnlyRoleId != null) roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly));
|
|
||||||
break;
|
|
||||||
case AppRoles.WriteOperate:
|
|
||||||
if (_writeOperateRoleId != null)
|
|
||||||
roles.Add(new Role(_writeOperateRoleId, AppRoles.WriteOperate));
|
|
||||||
break;
|
|
||||||
case AppRoles.WriteTune:
|
|
||||||
if (_writeTuneRoleId != null) roles.Add(new Role(_writeTuneRoleId, AppRoles.WriteTune));
|
|
||||||
break;
|
|
||||||
case AppRoles.WriteConfigure:
|
|
||||||
if (_writeConfigureRoleId != null)
|
|
||||||
roles.Add(new Role(_writeConfigureRoleId, AppRoles.WriteConfigure));
|
|
||||||
break;
|
|
||||||
case AppRoles.AlarmAck:
|
|
||||||
if (_alarmAckRoleId != null) roles.Add(new Role(_alarmAckRoleId, AppRoles.AlarmAck));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Information("AUDIT: Authentication SUCCESS for user {Username} with roles [{Roles}] session {SessionId}",
|
|
||||||
userNameToken.UserName, string.Join(", ", appRoles), session?.Id);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Log.Information("AUDIT: Authentication SUCCESS for user {Username} session {SessionId}",
|
|
||||||
userNameToken.UserName, session?.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
args.Identity = new RoleBasedIdentity(
|
|
||||||
new UserIdentity(userNameToken), roles);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.NewIdentity is X509IdentityToken x509Token)
|
|
||||||
{
|
|
||||||
var cert = x509Token.Certificate;
|
|
||||||
var subject = cert?.Subject ?? "Unknown";
|
|
||||||
|
|
||||||
// Extract CN from certificate subject for display
|
|
||||||
var cn = subject;
|
|
||||||
var cnStart = subject.IndexOf("CN=", StringComparison.OrdinalIgnoreCase);
|
|
||||||
if (cnStart >= 0)
|
|
||||||
{
|
|
||||||
cn = subject.Substring(cnStart + 3);
|
|
||||||
var commaIdx = cn.IndexOf(',');
|
|
||||||
if (commaIdx >= 0)
|
|
||||||
cn = cn.Substring(0, commaIdx);
|
|
||||||
}
|
|
||||||
|
|
||||||
var roles = new List<Role> { Role.AuthenticatedUser };
|
|
||||||
|
|
||||||
// X.509 authenticated users get ReadOnly role by default
|
|
||||||
if (_readOnlyRoleId != null)
|
|
||||||
roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly));
|
|
||||||
|
|
||||||
args.Identity = new RoleBasedIdentity(
|
|
||||||
new UserIdentity(x509Token), roles);
|
|
||||||
Log.Information("X509 certificate authenticated: CN={CN}, Subject={Subject}, Thumbprint={Thumbprint}",
|
|
||||||
cn, subject, cert?.Thumbprint);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Unsupported token type");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override ServerProperties LoadServerProperties()
|
|
||||||
{
|
|
||||||
var properties = new ServerProperties
|
|
||||||
{
|
|
||||||
ManufacturerName = "ZB MOM",
|
|
||||||
ProductName = "LmxOpcUa Server",
|
|
||||||
ProductUri = $"urn:{_galaxyName}:LmxOpcUa",
|
|
||||||
SoftwareVersion = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
|
||||||
BuildNumber = "1",
|
|
||||||
BuildDate = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
return properties;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
using Opc.Ua;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Maps domain Quality to OPC UA StatusCodes for the OPC UA server layer. (OPC-005)
|
|
||||||
/// </summary>
|
|
||||||
public static class OpcUaQualityMapper
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Converts bridge quality values into OPC UA status codes.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quality">The bridge quality value.</param>
|
|
||||||
/// <returns>The OPC UA status code to publish.</returns>
|
|
||||||
public static StatusCode ToStatusCode(Quality quality)
|
|
||||||
{
|
|
||||||
return new StatusCode(QualityMapper.MapToOpcUaStatusCode(quality));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Converts an OPC UA status code back into a bridge quality category.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="statusCode">The OPC UA status code to interpret.</param>
|
|
||||||
/// <returns>The bridge quality category represented by the status code.</returns>
|
|
||||||
public static Quality FromStatusCode(StatusCode statusCode)
|
|
||||||
{
|
|
||||||
if (StatusCode.IsGood(statusCode)) return Quality.Good;
|
|
||||||
if (StatusCode.IsUncertain(statusCode)) return Quality.Uncertain;
|
|
||||||
return Quality.Bad;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Opc.Ua;
|
|
||||||
using Opc.Ua.Configuration;
|
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Manages the OPC UA ApplicationInstance lifecycle. Programmatic config, no XML. (OPC-001, OPC-012, OPC-013)
|
|
||||||
/// </summary>
|
|
||||||
public class OpcUaServerHost : IDisposable
|
|
||||||
{
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<OpcUaServerHost>();
|
|
||||||
private readonly AlarmObjectFilter? _alarmObjectFilter;
|
|
||||||
private readonly AuthenticationConfiguration _authConfig;
|
|
||||||
private readonly IUserAuthenticationProvider? _authProvider;
|
|
||||||
|
|
||||||
private readonly OpcUaConfiguration _config;
|
|
||||||
private readonly IHistorianDataSource? _historianDataSource;
|
|
||||||
private readonly PerformanceMetrics _metrics;
|
|
||||||
private readonly IMxAccessClient _mxAccessClient;
|
|
||||||
private readonly RedundancyConfiguration _redundancyConfig;
|
|
||||||
private readonly SecurityProfileConfiguration _securityConfig;
|
|
||||||
private ApplicationInstance? _application;
|
|
||||||
private LmxOpcUaServer? _server;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new host for the Galaxy-backed OPC UA server instance.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="config">The endpoint and session settings for the OPC UA host.</param>
|
|
||||||
/// <param name="mxAccessClient">The runtime client used by the node manager for live reads, writes, and subscriptions.</param>
|
|
||||||
/// <param name="metrics">The metrics collector shared with the node manager and runtime bridge.</param>
|
|
||||||
/// <param name="historianDataSource">The optional historian adapter that enables OPC UA history read support.</param>
|
|
||||||
public OpcUaServerHost(OpcUaConfiguration config, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
|
|
||||||
IHistorianDataSource? historianDataSource = null,
|
|
||||||
AuthenticationConfiguration? authConfig = null,
|
|
||||||
IUserAuthenticationProvider? authProvider = null,
|
|
||||||
SecurityProfileConfiguration? securityConfig = null,
|
|
||||||
RedundancyConfiguration? redundancyConfig = null,
|
|
||||||
AlarmObjectFilter? alarmObjectFilter = null,
|
|
||||||
MxAccessConfiguration? mxAccessConfig = null,
|
|
||||||
HistorianConfiguration? historianConfig = null)
|
|
||||||
{
|
|
||||||
_config = config;
|
|
||||||
_mxAccessClient = mxAccessClient;
|
|
||||||
_metrics = metrics;
|
|
||||||
_historianDataSource = historianDataSource;
|
|
||||||
_authConfig = authConfig ?? new AuthenticationConfiguration();
|
|
||||||
_authProvider = authProvider;
|
|
||||||
_securityConfig = securityConfig ?? new SecurityProfileConfiguration();
|
|
||||||
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
|
|
||||||
_alarmObjectFilter = alarmObjectFilter;
|
|
||||||
_mxAccessConfig = mxAccessConfig ?? new MxAccessConfiguration();
|
|
||||||
_historianConfig = historianConfig ?? new HistorianConfiguration();
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly MxAccessConfiguration _mxAccessConfig;
|
|
||||||
private readonly HistorianConfiguration _historianConfig;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the active node manager that holds the published Galaxy namespace.
|
|
||||||
/// </summary>
|
|
||||||
public LmxNodeManager? NodeManager => _server?.NodeManager;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of currently connected OPC UA client sessions.
|
|
||||||
/// </summary>
|
|
||||||
public int ActiveSessionCount => _server?.ActiveSessionCount ?? 0;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a value indicating whether the OPC UA server has been started and not yet stopped.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsRunning => _server != null;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the list of opc.tcp base addresses the server is currently listening on.
|
|
||||||
/// Returns an empty list when the server has not started.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<string> BaseAddresses
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var addrs = _application?.ApplicationConfiguration?.ServerConfiguration?.BaseAddresses;
|
|
||||||
return addrs != null ? addrs.ToList() : Array.Empty<string>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the list of active security policies advertised to clients (SecurityMode + PolicyUri).
|
|
||||||
/// Returns an empty list when the server has not started.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<ServerSecurityPolicy> SecurityPolicies
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.SecurityPolicies;
|
|
||||||
return policies != null ? policies.ToList() : Array.Empty<ServerSecurityPolicy>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the list of user token policy names advertised to clients (Anonymous, UserName, Certificate).
|
|
||||||
/// Returns an empty list when the server has not started.
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<string> UserTokenPolicies
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var policies = _application?.ApplicationConfiguration?.ServerConfiguration?.UserTokenPolicies;
|
|
||||||
return policies != null ? policies.Select(p => p.TokenType.ToString()).ToList() : Array.Empty<string>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops the host and releases server resources.
|
|
||||||
/// </summary>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the OPC UA ServiceLevel based on current runtime health.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected)
|
|
||||||
{
|
|
||||||
_server?.UpdateServiceLevel(mxAccessConnected, dbConnected);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Starts the OPC UA application instance, prepares certificates, and binds the Galaxy namespace to the configured
|
|
||||||
/// endpoint.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
foreach (var sp in securityPolicies)
|
|
||||||
Log.Information("Security profile active: {PolicyUri} / {Mode}", sp.SecurityPolicyUri, sp.SecurityMode);
|
|
||||||
|
|
||||||
// Build PKI paths
|
|
||||||
var pkiRoot = _securityConfig.PkiRootPath ?? Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
||||||
"OPC Foundation", "pki");
|
|
||||||
var certSubject = _securityConfig.CertificateSubject ?? $"CN={_config.ServerName}, O=ZB MOM, DC=localhost";
|
|
||||||
|
|
||||||
var serverConfig = new ServerConfiguration
|
|
||||||
{
|
|
||||||
BaseAddresses = { $"opc.tcp://{_config.BindAddress}:{_config.Port}{_config.EndpointPath}" },
|
|
||||||
MaxSessionCount = _config.MaxSessions,
|
|
||||||
MaxSessionTimeout = _config.SessionTimeoutMinutes * 60 * 1000, // ms
|
|
||||||
MinSessionTimeout = 10000,
|
|
||||||
UserTokenPolicies = BuildUserTokenPolicies()
|
|
||||||
};
|
|
||||||
foreach (var policy in securityPolicies)
|
|
||||||
serverConfig.SecurityPolicies.Add(policy);
|
|
||||||
|
|
||||||
var secConfig = new SecurityConfiguration
|
|
||||||
{
|
|
||||||
ApplicationCertificate = new CertificateIdentifier
|
|
||||||
{
|
|
||||||
StoreType = CertificateStoreType.Directory,
|
|
||||||
StorePath = Path.Combine(pkiRoot, "own"),
|
|
||||||
SubjectName = certSubject
|
|
||||||
},
|
|
||||||
TrustedIssuerCertificates = new CertificateTrustList
|
|
||||||
{
|
|
||||||
StoreType = CertificateStoreType.Directory,
|
|
||||||
StorePath = Path.Combine(pkiRoot, "issuer")
|
|
||||||
},
|
|
||||||
TrustedPeerCertificates = new CertificateTrustList
|
|
||||||
{
|
|
||||||
StoreType = CertificateStoreType.Directory,
|
|
||||||
StorePath = Path.Combine(pkiRoot, "trusted")
|
|
||||||
},
|
|
||||||
RejectedCertificateStore = new CertificateTrustList
|
|
||||||
{
|
|
||||||
StoreType = CertificateStoreType.Directory,
|
|
||||||
StorePath = Path.Combine(pkiRoot, "rejected")
|
|
||||||
},
|
|
||||||
AutoAcceptUntrustedCertificates = _securityConfig.AutoAcceptClientCertificates,
|
|
||||||
RejectSHA1SignedCertificates = _securityConfig.RejectSHA1Certificates,
|
|
||||||
MinimumCertificateKeySize = (ushort)_securityConfig.MinimumCertificateKeySize
|
|
||||||
};
|
|
||||||
|
|
||||||
var appConfig = new ApplicationConfiguration
|
|
||||||
{
|
|
||||||
ApplicationName = _config.ServerName,
|
|
||||||
ApplicationUri = applicationUri,
|
|
||||||
ApplicationType = ApplicationType.Server,
|
|
||||||
ProductUri = namespaceUri,
|
|
||||||
ServerConfiguration = serverConfig,
|
|
||||||
SecurityConfiguration = secConfig,
|
|
||||||
|
|
||||||
TransportQuotas = new TransportQuotas
|
|
||||||
{
|
|
||||||
OperationTimeout = 120000,
|
|
||||||
MaxStringLength = 4 * 1024 * 1024,
|
|
||||||
MaxByteStringLength = 4 * 1024 * 1024,
|
|
||||||
MaxArrayLength = 65535,
|
|
||||||
MaxMessageSize = 4 * 1024 * 1024,
|
|
||||||
MaxBufferSize = 65535,
|
|
||||||
ChannelLifetime = 600000,
|
|
||||||
SecurityTokenLifetime = 3600000
|
|
||||||
},
|
|
||||||
|
|
||||||
TraceConfiguration = new TraceConfiguration
|
|
||||||
{
|
|
||||||
OutputFilePath = null,
|
|
||||||
TraceMasks = 0
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await appConfig.Validate(ApplicationType.Server);
|
|
||||||
|
|
||||||
// Hook certificate validation logging
|
|
||||||
appConfig.CertificateValidator.CertificateValidation += OnCertificateValidation;
|
|
||||||
|
|
||||||
_application = new ApplicationInstance
|
|
||||||
{
|
|
||||||
ApplicationName = _config.ServerName,
|
|
||||||
ApplicationType = ApplicationType.Server,
|
|
||||||
ApplicationConfiguration = appConfig
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check/create application certificate
|
|
||||||
var minKeySize = (ushort)_securityConfig.MinimumCertificateKeySize;
|
|
||||||
var certLifetimeMonths = (ushort)_securityConfig.CertificateLifetimeMonths;
|
|
||||||
var certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize, certLifetimeMonths);
|
|
||||||
if (!certOk)
|
|
||||||
{
|
|
||||||
Log.Warning("Application certificate check failed, attempting to create...");
|
|
||||||
certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize, certLifetimeMonths);
|
|
||||||
}
|
|
||||||
|
|
||||||
_server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource,
|
|
||||||
_config.AlarmTrackingEnabled, _authConfig, _authProvider, _redundancyConfig, applicationUri,
|
|
||||||
_alarmObjectFilter,
|
|
||||||
_mxAccessConfig.RuntimeStatusProbesEnabled,
|
|
||||||
_mxAccessConfig.RuntimeStatusUnknownTimeoutSeconds,
|
|
||||||
_mxAccessConfig.RequestTimeoutSeconds,
|
|
||||||
_historianConfig.RequestTimeoutSeconds);
|
|
||||||
await _application.Start(_server);
|
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
var cert = e.Certificate;
|
|
||||||
var subject = cert?.Subject ?? "Unknown";
|
|
||||||
var thumbprint = cert?.Thumbprint ?? "N/A";
|
|
||||||
|
|
||||||
if (_securityConfig.AutoAcceptClientCertificates)
|
|
||||||
{
|
|
||||||
e.Accept = true;
|
|
||||||
Log.Warning(
|
|
||||||
"Client certificate auto-accepted: Subject={Subject}, Thumbprint={Thumbprint}, ValidTo={ValidTo}",
|
|
||||||
subject, thumbprint, cert?.NotAfter.ToString("yyyy-MM-dd"));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Log.Warning(
|
|
||||||
"Client certificate validation: Error={Error}, Subject={Subject}, Thumbprint={Thumbprint}, Accepted={Accepted}",
|
|
||||||
e.Error?.StatusCode, subject, thumbprint, e.Accept);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops the OPC UA application instance and releases its in-memory server objects.
|
|
||||||
/// </summary>
|
|
||||||
public void Stop()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_server?.Stop();
|
|
||||||
Log.Information("OPC UA server stopped");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Error stopping OPC UA server");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_server = null;
|
|
||||||
_application = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private UserTokenPolicyCollection BuildUserTokenPolicies()
|
|
||||||
{
|
|
||||||
var policies = new UserTokenPolicyCollection();
|
|
||||||
if (_authConfig.AllowAnonymous)
|
|
||||||
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
|
|
||||||
if (_authConfig.Ldap.Enabled || _authProvider != null)
|
|
||||||
policies.Add(new UserTokenPolicy(UserTokenType.UserName));
|
|
||||||
|
|
||||||
// X.509 certificate authentication is always available when security is configured
|
|
||||||
if (_securityConfig.Profiles.Any(p =>
|
|
||||||
!p.Equals("None", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
policies.Add(new UserTokenPolicy(UserTokenType.Certificate));
|
|
||||||
|
|
||||||
if (policies.Count == 0)
|
|
||||||
{
|
|
||||||
Log.Warning("No authentication methods configured — adding Anonymous as fallback");
|
|
||||||
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
|
|
||||||
}
|
|
||||||
|
|
||||||
return policies;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
using Opc.Ua;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using Opc.Ua;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Maps configured security profile names to OPC UA <see cref="ServerSecurityPolicy" /> instances.
|
|
||||||
/// </summary>
|
|
||||||
public static class SecurityProfileResolver
|
|
||||||
{
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(SecurityProfileResolver));
|
|
||||||
|
|
||||||
private static readonly Dictionary<string, ServerSecurityPolicy> KnownProfiles =
|
|
||||||
new(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
["None"] = new ServerSecurityPolicy
|
|
||||||
{
|
|
||||||
SecurityMode = MessageSecurityMode.None,
|
|
||||||
SecurityPolicyUri = SecurityPolicies.None
|
|
||||||
},
|
|
||||||
["Basic256Sha256-Sign"] = new ServerSecurityPolicy
|
|
||||||
{
|
|
||||||
SecurityMode = MessageSecurityMode.Sign,
|
|
||||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
|
|
||||||
},
|
|
||||||
["Basic256Sha256-SignAndEncrypt"] = new ServerSecurityPolicy
|
|
||||||
{
|
|
||||||
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
|
||||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
|
|
||||||
},
|
|
||||||
["Aes128_Sha256_RsaOaep-Sign"] = new ServerSecurityPolicy
|
|
||||||
{
|
|
||||||
SecurityMode = MessageSecurityMode.Sign,
|
|
||||||
SecurityPolicyUri = SecurityPolicies.Aes128_Sha256_RsaOaep
|
|
||||||
},
|
|
||||||
["Aes128_Sha256_RsaOaep-SignAndEncrypt"] = new ServerSecurityPolicy
|
|
||||||
{
|
|
||||||
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
|
||||||
SecurityPolicyUri = SecurityPolicies.Aes128_Sha256_RsaOaep
|
|
||||||
},
|
|
||||||
["Aes256_Sha256_RsaPss-Sign"] = new ServerSecurityPolicy
|
|
||||||
{
|
|
||||||
SecurityMode = MessageSecurityMode.Sign,
|
|
||||||
SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss
|
|
||||||
},
|
|
||||||
["Aes256_Sha256_RsaPss-SignAndEncrypt"] = new ServerSecurityPolicy
|
|
||||||
{
|
|
||||||
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
|
||||||
SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the list of valid profile names for validation and documentation.
|
|
||||||
/// </summary>
|
|
||||||
public static IReadOnlyCollection<string> ValidProfileNames => KnownProfiles.Keys.ToList().AsReadOnly();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resolves the configured profile names to <see cref="ServerSecurityPolicy" /> entries.
|
|
||||||
/// Unknown names are skipped with a warning. An empty or fully-invalid list falls back to <c>None</c>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="profileNames">The profile names from configuration.</param>
|
|
||||||
/// <returns>A deduplicated list of server security policies.</returns>
|
|
||||||
public static List<ServerSecurityPolicy> Resolve(IReadOnlyCollection<string> profileNames)
|
|
||||||
{
|
|
||||||
var resolved = new List<ServerSecurityPolicy>();
|
|
||||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
foreach (var name in profileNames ?? Array.Empty<string>())
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var trimmed = name.Trim();
|
|
||||||
|
|
||||||
if (!seen.Add(trimmed))
|
|
||||||
{
|
|
||||||
Log.Debug("Skipping duplicate security profile: {Profile}", trimmed);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (KnownProfiles.TryGetValue(trimmed, out var policy))
|
|
||||||
resolved.Add(policy);
|
|
||||||
else
|
|
||||||
Log.Warning("Unknown security profile '{Profile}' — skipping. Valid profiles: {ValidProfiles}",
|
|
||||||
trimmed, string.Join(", ", KnownProfiles.Keys));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolved.Count == 0)
|
|
||||||
{
|
|
||||||
Log.Warning("No valid security profiles configured — falling back to None");
|
|
||||||
resolved.Add(KnownProfiles["None"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.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;
|
|
||||||
|
|
||||||
var level = baseLevel;
|
|
||||||
|
|
||||||
if (!mxAccessConnected)
|
|
||||||
level -= 100;
|
|
||||||
|
|
||||||
if (!dbConnected)
|
|
||||||
level -= 50;
|
|
||||||
|
|
||||||
return (byte)Math.Max(0, Math.Min(level, 255));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,532 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Serilog;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.MxAccess;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Status;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Full service implementation wiring all components together. (SVC-004, SVC-005, SVC-006)
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class OpcUaService
|
|
||||||
{
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<OpcUaService>();
|
|
||||||
private readonly IUserAuthenticationProvider? _authProviderOverride;
|
|
||||||
|
|
||||||
private readonly AppConfiguration _config;
|
|
||||||
private readonly IGalaxyRepository? _galaxyRepository;
|
|
||||||
private readonly bool _hasAuthProviderOverride;
|
|
||||||
private readonly bool _hasMxAccessClientOverride;
|
|
||||||
private readonly IMxAccessClient? _mxAccessClientOverride;
|
|
||||||
private readonly IMxProxy? _mxProxy;
|
|
||||||
|
|
||||||
private CancellationTokenSource? _cts;
|
|
||||||
private HealthCheckService? _healthCheck;
|
|
||||||
private IHistorianDataSource? _historianDataSource;
|
|
||||||
private MxAccessClient? _mxAccessClient;
|
|
||||||
private IMxAccessClient? _mxAccessClientForWiring;
|
|
||||||
private StaComThread? _staThread;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Production constructor. Loads configuration from appsettings.json.
|
|
||||||
/// </summary>
|
|
||||||
public OpcUaService()
|
|
||||||
{
|
|
||||||
var configuration = new ConfigurationBuilder()
|
|
||||||
.AddJsonFile("appsettings.json", false)
|
|
||||||
.AddJsonFile(
|
|
||||||
$"appsettings.{Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"}.json",
|
|
||||||
true)
|
|
||||||
.AddEnvironmentVariables()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
_config = new AppConfiguration();
|
|
||||||
configuration.GetSection("OpcUa").Bind(_config.OpcUa);
|
|
||||||
configuration.GetSection("MxAccess").Bind(_config.MxAccess);
|
|
||||||
configuration.GetSection("GalaxyRepository").Bind(_config.GalaxyRepository);
|
|
||||||
configuration.GetSection("Dashboard").Bind(_config.Dashboard);
|
|
||||||
configuration.GetSection("Historian").Bind(_config.Historian);
|
|
||||||
configuration.GetSection("Authentication").Bind(_config.Authentication);
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Test constructor. Accepts injected dependencies.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="config">
|
|
||||||
/// The service configuration used to shape OPC UA hosting, MXAccess connectivity, and dashboard
|
|
||||||
/// behavior during the test run.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="mxProxy">The MXAccess proxy substitute used when a test wants to exercise COM-style wiring.</param>
|
|
||||||
/// <param name="galaxyRepository">
|
|
||||||
/// The repository substitute that supplies Galaxy hierarchy and deploy metadata for
|
|
||||||
/// address-space builds.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="mxAccessClientOverride">
|
|
||||||
/// An optional direct MXAccess client substitute that bypasses STA thread setup and
|
|
||||||
/// COM interop.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="hasMxAccessClientOverride">
|
|
||||||
/// A value indicating whether the override client should be used instead of
|
|
||||||
/// creating a client from <paramref name="mxProxy" />.
|
|
||||||
/// </param>
|
|
||||||
internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository,
|
|
||||||
IMxAccessClient? mxAccessClientOverride = null, bool hasMxAccessClientOverride = false,
|
|
||||||
IUserAuthenticationProvider? authProviderOverride = null, bool hasAuthProviderOverride = false)
|
|
||||||
{
|
|
||||||
_config = config;
|
|
||||||
_mxProxy = mxProxy;
|
|
||||||
_galaxyRepository = galaxyRepository;
|
|
||||||
_mxAccessClientOverride = mxAccessClientOverride;
|
|
||||||
_hasMxAccessClientOverride = hasMxAccessClientOverride;
|
|
||||||
_authProviderOverride = authProviderOverride;
|
|
||||||
_hasAuthProviderOverride = hasAuthProviderOverride;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accessors for testing
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the MXAccess client instance currently wired into the service for test inspection.
|
|
||||||
/// </summary>
|
|
||||||
internal IMxAccessClient? MxClient => (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the metrics collector that tracks bridge operation timings during the service lifetime.
|
|
||||||
/// </summary>
|
|
||||||
internal PerformanceMetrics? Metrics { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the OPC UA server host that owns the runtime endpoint.
|
|
||||||
/// </summary>
|
|
||||||
internal OpcUaServerHost? ServerHost { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the node manager instance that holds the current Galaxy-derived address space.
|
|
||||||
/// </summary>
|
|
||||||
internal LmxNodeManager? NodeManagerInstance { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the change-detection service that watches for Galaxy deploys requiring a rebuild.
|
|
||||||
/// </summary>
|
|
||||||
internal ChangeDetectionService? ChangeDetectionInstance { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the hosted status web server when the dashboard is enabled and successfully bound.
|
|
||||||
/// Null when <c>Dashboard.Enabled</c> is false or when <see cref="DashboardStartFailed"/> is true.
|
|
||||||
/// </summary>
|
|
||||||
internal StatusWebServer? StatusWeb { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a flag indicating that the dashboard was enabled in configuration but failed to bind
|
|
||||||
/// its HTTP port at startup. The service continues in degraded mode (matching the pattern
|
|
||||||
/// for other optional subsystems: MxAccess connect, Galaxy DB connect, initial address space
|
|
||||||
/// build). Surfaced for tests and any external health probe that needs to distinguish
|
|
||||||
/// "dashboard disabled by config" from "dashboard failed to start".
|
|
||||||
/// </summary>
|
|
||||||
internal bool DashboardStartFailed { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the dashboard report generator used to assemble operator-facing status snapshots.
|
|
||||||
/// </summary>
|
|
||||||
internal StatusReportService? StatusReportInstance { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the Galaxy statistics snapshot populated during repository reads and rebuilds.
|
|
||||||
/// </summary>
|
|
||||||
internal GalaxyRepositoryStats? GalaxyStatsInstance { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Starts the bridge by validating configuration, connecting runtime dependencies, building the Galaxy-backed OPC UA
|
|
||||||
/// address space, and optionally hosting the status dashboard.
|
|
||||||
/// </summary>
|
|
||||||
public void Start()
|
|
||||||
{
|
|
||||||
Log.Information("LmxOpcUa service starting");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Step 2: Validate config
|
|
||||||
if (!ConfigurationValidator.ValidateAndLog(_config))
|
|
||||||
{
|
|
||||||
Log.Error("Configuration validation failed");
|
|
||||||
throw new InvalidOperationException("Configuration validation failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Register exception handler (SVC-006)
|
|
||||||
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
|
|
||||||
|
|
||||||
// Step 4: Create PerformanceMetrics
|
|
||||||
_cts = new CancellationTokenSource();
|
|
||||||
Metrics = new PerformanceMetrics();
|
|
||||||
|
|
||||||
// Step 5: Create MxAccessClient → Connect
|
|
||||||
if (_hasMxAccessClientOverride)
|
|
||||||
{
|
|
||||||
// Test path: use injected IMxAccessClient directly (skips STA thread + COM)
|
|
||||||
_mxAccessClientForWiring = _mxAccessClientOverride;
|
|
||||||
if (_mxAccessClientForWiring != null && _mxAccessClientForWiring.State != ConnectionState.Connected)
|
|
||||||
_mxAccessClientForWiring.ConnectAsync(_cts.Token).GetAwaiter().GetResult();
|
|
||||||
}
|
|
||||||
else if (_mxProxy != null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_staThread = new StaComThread();
|
|
||||||
_staThread.Start();
|
|
||||||
_mxAccessClient = new MxAccessClient(_staThread, _mxProxy, _config.MxAccess, Metrics);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_mxAccessClient.ConnectAsync(_cts.Token).GetAwaiter().GetResult();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex,
|
|
||||||
"MxAccess connection failed at startup - monitor will continue retrying in the background");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 6: Start monitor loop even if initial connect failed
|
|
||||||
_mxAccessClient.StartMonitor();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "MxAccess initialization failed - continuing without runtime data access");
|
|
||||||
_mxAccessClient?.Dispose();
|
|
||||||
_mxAccessClient = null;
|
|
||||||
_staThread?.Dispose();
|
|
||||||
_staThread = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 7: Create GalaxyRepositoryService → TestConnection
|
|
||||||
GalaxyStatsInstance = new GalaxyRepositoryStats { GalaxyName = _config.OpcUa.GalaxyName };
|
|
||||||
|
|
||||||
if (_galaxyRepository != null)
|
|
||||||
{
|
|
||||||
var dbOk = _galaxyRepository.TestConnectionAsync(_cts.Token).GetAwaiter().GetResult();
|
|
||||||
GalaxyStatsInstance.DbConnected = dbOk;
|
|
||||||
if (!dbOk)
|
|
||||||
Log.Warning("Galaxy repository database connection failed — continuing without initial data");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 8: Create OPC UA server host + node manager
|
|
||||||
var effectiveMxClient = (IMxAccessClient?)_mxAccessClient ??
|
|
||||||
_mxAccessClientForWiring ?? new NullMxAccessClient();
|
|
||||||
if (_config.Historian.Enabled)
|
|
||||||
{
|
|
||||||
_historianDataSource = HistorianPluginLoader.TryLoad(_config.Historian);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
HistorianPluginLoader.MarkDisabled();
|
|
||||||
_historianDataSource = null;
|
|
||||||
}
|
|
||||||
IUserAuthenticationProvider? authProvider = null;
|
|
||||||
if (_hasAuthProviderOverride)
|
|
||||||
{
|
|
||||||
authProvider = _authProviderOverride;
|
|
||||||
}
|
|
||||||
else if (_config.Authentication.Ldap.Enabled)
|
|
||||||
{
|
|
||||||
authProvider = new LdapAuthenticationProvider(_config.Authentication.Ldap);
|
|
||||||
Log.Information("LDAP authentication enabled (server={Host}:{Port}, baseDN={BaseDN})",
|
|
||||||
_config.Authentication.Ldap.Host, _config.Authentication.Ldap.Port,
|
|
||||||
_config.Authentication.Ldap.BaseDN);
|
|
||||||
}
|
|
||||||
|
|
||||||
var alarmObjectFilter = new AlarmObjectFilter(_config.OpcUa.AlarmFilter);
|
|
||||||
if (alarmObjectFilter.Enabled)
|
|
||||||
Log.Information(
|
|
||||||
"Alarm object filter compiled with {PatternCount} pattern(s): [{Patterns}]",
|
|
||||||
alarmObjectFilter.PatternCount,
|
|
||||||
string.Join(", ", _config.OpcUa.AlarmFilter.ObjectFilters));
|
|
||||||
|
|
||||||
ServerHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, Metrics, _historianDataSource,
|
|
||||||
_config.Authentication, authProvider, _config.Security, _config.Redundancy, alarmObjectFilter,
|
|
||||||
_config.MxAccess, _config.Historian);
|
|
||||||
|
|
||||||
// Step 9-10: Query hierarchy, start server, build address space
|
|
||||||
DateTime? initialDeployTime = null;
|
|
||||||
if (_galaxyRepository != null && GalaxyStatsInstance.DbConnected)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
initialDeployTime = _galaxyRepository.GetLastDeployTimeAsync(_cts.Token).GetAwaiter()
|
|
||||||
.GetResult();
|
|
||||||
var hierarchy = _galaxyRepository.GetHierarchyAsync(_cts.Token).GetAwaiter().GetResult();
|
|
||||||
var attributes = _galaxyRepository.GetAttributesAsync(_cts.Token).GetAwaiter().GetResult();
|
|
||||||
GalaxyStatsInstance.ObjectCount = hierarchy.Count;
|
|
||||||
GalaxyStatsInstance.AttributeCount = attributes.Count;
|
|
||||||
|
|
||||||
ServerHost.StartAsync().GetAwaiter().GetResult();
|
|
||||||
NodeManagerInstance = ServerHost.NodeManager;
|
|
||||||
|
|
||||||
if (NodeManagerInstance != null)
|
|
||||||
{
|
|
||||||
NodeManagerInstance.BuildAddressSpace(hierarchy, attributes);
|
|
||||||
GalaxyStatsInstance.LastRebuildTime = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Failed to build initial address space");
|
|
||||||
if (!ServerHost.IsRunning)
|
|
||||||
{
|
|
||||||
ServerHost.StartAsync().GetAwaiter().GetResult();
|
|
||||||
NodeManagerInstance = ServerHost.NodeManager;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ServerHost.StartAsync().GetAwaiter().GetResult();
|
|
||||||
NodeManagerInstance = ServerHost.NodeManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 11-12: Change detection wired to rebuild
|
|
||||||
if (_galaxyRepository != null)
|
|
||||||
{
|
|
||||||
ChangeDetectionInstance = new ChangeDetectionService(_galaxyRepository,
|
|
||||||
_config.GalaxyRepository.ChangeDetectionIntervalSeconds, initialDeployTime);
|
|
||||||
ChangeDetectionInstance.OnGalaxyChanged += OnGalaxyChanged;
|
|
||||||
ChangeDetectionInstance.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 13: Dashboard
|
|
||||||
_healthCheck = new HealthCheckService();
|
|
||||||
StatusReportInstance = new StatusReportService(_healthCheck, _config.Dashboard.RefreshIntervalSeconds);
|
|
||||||
StatusReportInstance.SetComponents(effectiveMxClient, Metrics, GalaxyStatsInstance, ServerHost,
|
|
||||||
NodeManagerInstance,
|
|
||||||
_config.Redundancy, _config.OpcUa.ApplicationUri, _config.Historian);
|
|
||||||
|
|
||||||
if (_config.Dashboard.Enabled)
|
|
||||||
{
|
|
||||||
var dashboardServer = new StatusWebServer(StatusReportInstance, _config.Dashboard.Port);
|
|
||||||
if (dashboardServer.Start())
|
|
||||||
{
|
|
||||||
StatusWeb = dashboardServer;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Degraded mode: StatusWebServer.Start() already logged the underlying exception.
|
|
||||||
// Dispose the unstarted instance, null out the reference, and flag the failure so
|
|
||||||
// tests and health probes can observe it. Service startup continues.
|
|
||||||
Log.Warning("Status dashboard failed to bind on port {Port}; service continues without dashboard",
|
|
||||||
_config.Dashboard.Port);
|
|
||||||
dashboardServer.Dispose();
|
|
||||||
DashboardStartFailed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wire ServiceLevel updates from MXAccess health changes
|
|
||||||
if (_config.Redundancy.Enabled)
|
|
||||||
effectiveMxClient.ConnectionStateChanged += OnMxAccessStateChangedForServiceLevel;
|
|
||||||
|
|
||||||
// Step 14
|
|
||||||
Log.Information("LmxOpcUa service started successfully");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Fatal(ex, "LmxOpcUa service failed to start");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops the bridge, cancels monitoring loops, disconnects runtime integrations, and releases hosted resources in
|
|
||||||
/// shutdown order.
|
|
||||||
/// </summary>
|
|
||||||
public void Stop()
|
|
||||||
{
|
|
||||||
Log.Information("LmxOpcUa service stopping");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_cts?.Cancel();
|
|
||||||
ChangeDetectionInstance?.Stop();
|
|
||||||
ServerHost?.Stop();
|
|
||||||
|
|
||||||
if (_mxAccessClient != null)
|
|
||||||
{
|
|
||||||
_mxAccessClient.StopMonitor();
|
|
||||||
_mxAccessClient.DisconnectAsync().GetAwaiter().GetResult();
|
|
||||||
_mxAccessClient.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
_staThread?.Dispose();
|
|
||||||
_historianDataSource?.Dispose();
|
|
||||||
|
|
||||||
StatusWeb?.Dispose();
|
|
||||||
Metrics?.Dispose();
|
|
||||||
ChangeDetectionInstance?.Dispose();
|
|
||||||
_cts?.Dispose();
|
|
||||||
|
|
||||||
AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Error during service shutdown");
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Information("Service shutdown complete");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnGalaxyChanged()
|
|
||||||
{
|
|
||||||
Log.Information("Galaxy change detected — rebuilding address space");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_galaxyRepository == null || NodeManagerInstance == null) return;
|
|
||||||
|
|
||||||
var hierarchy = _galaxyRepository.GetHierarchyAsync().GetAwaiter().GetResult();
|
|
||||||
var attributes = _galaxyRepository.GetAttributesAsync().GetAwaiter().GetResult();
|
|
||||||
|
|
||||||
NodeManagerInstance.RebuildAddressSpace(hierarchy, attributes);
|
|
||||||
|
|
||||||
if (GalaxyStatsInstance != null)
|
|
||||||
{
|
|
||||||
GalaxyStatsInstance.ObjectCount = hierarchy.Count;
|
|
||||||
GalaxyStatsInstance.AttributeCount = attributes.Count;
|
|
||||||
GalaxyStatsInstance.LastRebuildTime = DateTime.UtcNow;
|
|
||||||
GalaxyStatsInstance.LastDeployTime = ChangeDetectionInstance?.LastKnownDeployTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Failed to rebuild address space");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnMxAccessStateChangedForServiceLevel(object? sender, ConnectionStateChangedEventArgs e)
|
|
||||||
{
|
|
||||||
var mxConnected = e.CurrentState == ConnectionState.Connected;
|
|
||||||
var dbConnected = GalaxyStatsInstance?.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Triggers an address space rebuild from the current Galaxy repository data. For testing.
|
|
||||||
/// </summary>
|
|
||||||
internal void TriggerRebuild()
|
|
||||||
{
|
|
||||||
OnGalaxyChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Null implementation of IMxAccessClient for when MXAccess is not available.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class NullMxAccessClient : IMxAccessClient
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the disconnected state reported when the bridge is running without live MXAccess connectivity.
|
|
||||||
/// </summary>
|
|
||||||
public ConnectionState State => ConnectionState.Disconnected;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the active subscription count, which is always zero for the null runtime client.
|
|
||||||
/// </summary>
|
|
||||||
public int ActiveSubscriptionCount => 0;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the reconnect count, which is always zero because the null client never establishes a session.
|
|
||||||
/// </summary>
|
|
||||||
public int ReconnectCount => 0;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when the runtime connection state changes. The null client never raises this event.
|
|
||||||
/// </summary>
|
|
||||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when a subscribed tag value changes. The null client never raises this event.
|
|
||||||
/// </summary>
|
|
||||||
public event Action<string, Vtq>? OnTagValueChanged;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Completes immediately because no live runtime connection is available or required.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
|
|
||||||
public Task ConnectAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Completes immediately because there is no live runtime session to close.
|
|
||||||
/// </summary>
|
|
||||||
public Task DisconnectAsync()
|
|
||||||
{
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Completes immediately because the null client does not subscribe to live Galaxy attributes.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fullTagReference">The tag reference that would have been subscribed.</param>
|
|
||||||
/// <param name="callback">The callback that would have received runtime value changes.</param>
|
|
||||||
public Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback)
|
|
||||||
{
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Completes immediately because the null client does not maintain runtime subscriptions.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fullTagReference">The tag reference that would have been unsubscribed.</param>
|
|
||||||
public Task UnsubscribeAsync(string fullTagReference)
|
|
||||||
{
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns a bad-quality value because no live runtime source exists.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fullTagReference">The tag reference that would have been read from the runtime.</param>
|
|
||||||
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
|
|
||||||
/// <returns>A bad-quality VTQ indicating that runtime data is unavailable.</returns>
|
|
||||||
public Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
return Task.FromResult(Vtq.Bad());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Rejects writes because there is no live runtime endpoint behind the null client.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fullTagReference">The tag reference that would have been written.</param>
|
|
||||||
/// <param name="value">The value that would have been sent to the runtime.</param>
|
|
||||||
/// <param name="ct">A cancellation token that is ignored by the null implementation.</param>
|
|
||||||
/// <returns>A completed task returning <see langword="false" />.</returns>
|
|
||||||
public Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
return Task.FromResult(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Releases the null client. No unmanaged runtime resources exist.
|
|
||||||
/// </summary>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Fluent builder for constructing OpcUaService with dependency overrides.
|
|
||||||
/// Used by integration tests to substitute fakes for COM/DB components.
|
|
||||||
/// </summary>
|
|
||||||
internal class OpcUaServiceBuilder
|
|
||||||
{
|
|
||||||
private IUserAuthenticationProvider? _authProvider;
|
|
||||||
private bool _authProviderSet;
|
|
||||||
private AppConfiguration _config = new();
|
|
||||||
private IGalaxyRepository? _galaxyRepository;
|
|
||||||
private bool _galaxyRepositorySet;
|
|
||||||
private IMxAccessClient? _mxAccessClient;
|
|
||||||
private bool _mxAccessClientSet;
|
|
||||||
private IMxProxy? _mxProxy;
|
|
||||||
private bool _mxProxySet;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Replaces the default service configuration used by the test host.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="config">The full configuration snapshot to inject into the service under test.</param>
|
|
||||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
|
||||||
public OpcUaServiceBuilder WithConfig(AppConfiguration config)
|
|
||||||
{
|
|
||||||
_config = config;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sets the OPC UA port used by the test host so multiple integration runs can coexist.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="port">The TCP port to expose for the test server.</param>
|
|
||||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
|
||||||
public OpcUaServiceBuilder WithOpcUaPort(int port)
|
|
||||||
{
|
|
||||||
_config.OpcUa.Port = port;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sets the Galaxy name represented by the test address space.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="name">The Galaxy name to expose through OPC UA and diagnostics.</param>
|
|
||||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
|
||||||
public OpcUaServiceBuilder WithGalaxyName(string name)
|
|
||||||
{
|
|
||||||
_config.OpcUa.GalaxyName = name;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Injects an MXAccess proxy substitute for tests that exercise the proxy-driven runtime path.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="proxy">The proxy fake or stub to supply to the service.</param>
|
|
||||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
|
||||||
public OpcUaServiceBuilder WithMxProxy(IMxProxy? proxy)
|
|
||||||
{
|
|
||||||
_mxProxy = proxy;
|
|
||||||
_mxProxySet = true;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Injects a repository substitute for tests that control Galaxy hierarchy and deploy metadata.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="repository">The repository fake or stub to supply to the service.</param>
|
|
||||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
|
||||||
public OpcUaServiceBuilder WithGalaxyRepository(IGalaxyRepository? repository)
|
|
||||||
{
|
|
||||||
_galaxyRepository = repository;
|
|
||||||
_galaxyRepositorySet = true;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Override the MxAccessClient directly, skipping STA thread and COM interop entirely.
|
|
||||||
/// When set, the service will use this client instead of creating one from IMxProxy.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="client">The direct MXAccess client substitute to inject into the service.</param>
|
|
||||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
|
||||||
public OpcUaServiceBuilder WithMxAccessClient(IMxAccessClient? client)
|
|
||||||
{
|
|
||||||
_mxAccessClient = client;
|
|
||||||
_mxAccessClientSet = true;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Seeds a convenience fake repository with Galaxy hierarchy and attribute rows for address-space tests.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="hierarchy">The object hierarchy to expose through the test OPC UA namespace.</param>
|
|
||||||
/// <param name="attributes">The attribute rows to attach to the hierarchy.</param>
|
|
||||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
|
||||||
public OpcUaServiceBuilder WithHierarchy(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
|
|
||||||
{
|
|
||||||
if (!_galaxyRepositorySet)
|
|
||||||
{
|
|
||||||
var fake = new FakeBuilderGalaxyRepository();
|
|
||||||
_galaxyRepository = fake;
|
|
||||||
_galaxyRepositorySet = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_galaxyRepository is FakeBuilderGalaxyRepository fakeRepo)
|
|
||||||
{
|
|
||||||
fakeRepo.Hierarchy = hierarchy;
|
|
||||||
fakeRepo.Attributes = attributes;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Disables the embedded dashboard so tests can focus on the runtime bridge without binding the HTTP listener.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
|
||||||
/// <summary>
|
|
||||||
/// Injects a custom authentication provider for tests that need deterministic role resolution.
|
|
||||||
/// </summary>
|
|
||||||
public OpcUaServiceBuilder WithAuthProvider(IUserAuthenticationProvider? provider)
|
|
||||||
{
|
|
||||||
_authProvider = provider;
|
|
||||||
_authProviderSet = true;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sets the authentication configuration for the test host.
|
|
||||||
/// </summary>
|
|
||||||
public OpcUaServiceBuilder WithAuthentication(AuthenticationConfiguration authConfig)
|
|
||||||
{
|
|
||||||
_config.Authentication = authConfig;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public OpcUaServiceBuilder DisableDashboard()
|
|
||||||
{
|
|
||||||
_config.Dashboard.Enabled = false;
|
|
||||||
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>
|
|
||||||
/// <param name="security">The security profile configuration to inject.</param>
|
|
||||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
|
||||||
/// <summary>
|
|
||||||
/// Enables alarm condition tracking on the test host so integration tests can exercise the alarm-creation path.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="enabled">Whether alarm tracking should be enabled.</param>
|
|
||||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
|
||||||
public OpcUaServiceBuilder WithAlarmTracking(bool enabled)
|
|
||||||
{
|
|
||||||
_config.OpcUa.AlarmTrackingEnabled = enabled;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configures the template-based alarm object filter for integration tests.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="filters">Zero or more wildcard patterns. Empty → filter disabled.</param>
|
|
||||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
|
||||||
public OpcUaServiceBuilder WithAlarmFilter(params string[] filters)
|
|
||||||
{
|
|
||||||
_config.OpcUa.AlarmFilter = new AlarmFilterConfiguration
|
|
||||||
{
|
|
||||||
ObjectFilters = filters.ToList()
|
|
||||||
};
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public OpcUaServiceBuilder WithSecurity(SecurityProfileConfiguration security)
|
|
||||||
{
|
|
||||||
_config.Security = security;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Effectively disables Galaxy change detection by pushing the polling interval beyond realistic test durations.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>The current builder so additional overrides can be chained.</returns>
|
|
||||||
public OpcUaServiceBuilder DisableChangeDetection()
|
|
||||||
{
|
|
||||||
_config.GalaxyRepository.ChangeDetectionIntervalSeconds = int.MaxValue;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates an <see cref="OpcUaService" /> using the accumulated test doubles and configuration overrides.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A service instance ready for integration-style testing.</returns>
|
|
||||||
public OpcUaService Build()
|
|
||||||
{
|
|
||||||
return new OpcUaService(
|
|
||||||
_config,
|
|
||||||
_mxProxySet ? _mxProxy : null,
|
|
||||||
_galaxyRepositorySet ? _galaxyRepository : null,
|
|
||||||
_mxAccessClientSet ? _mxAccessClient : null,
|
|
||||||
_mxAccessClientSet,
|
|
||||||
_authProviderSet ? _authProvider : null,
|
|
||||||
_authProviderSet);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Internal fake repository used by WithHierarchy for convenience.
|
|
||||||
/// </summary>
|
|
||||||
private class FakeBuilderGalaxyRepository : IGalaxyRepository
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the hierarchy rows that the fake repository returns to the service.
|
|
||||||
/// </summary>
|
|
||||||
public List<GalaxyObjectInfo> Hierarchy { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the attribute rows that the fake repository returns to the service.
|
|
||||||
/// </summary>
|
|
||||||
public List<GalaxyAttributeInfo> Attributes { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when the fake repository wants to simulate a Galaxy deploy change.
|
|
||||||
/// </summary>
|
|
||||||
public event Action? OnGalaxyChanged;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the seeded hierarchy rows for address-space construction.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
|
||||||
/// <returns>The configured hierarchy rows.</returns>
|
|
||||||
public Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
return Task.FromResult(Hierarchy);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the seeded attribute rows for address-space construction.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
|
||||||
/// <returns>The configured attribute rows.</returns>
|
|
||||||
public Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
return Task.FromResult(Attributes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the current UTC time so change-detection tests have a deploy timestamp to compare against.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
|
||||||
/// <returns>The current UTC time.</returns>
|
|
||||||
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
return Task.FromResult<DateTime?>(DateTime.UtcNow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reports a healthy repository connection for builder-based test setups.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
|
||||||
/// <returns>A completed task returning <see langword="true" />.</returns>
|
|
||||||
public Task<bool> TestConnectionAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
return Task.FromResult(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Serilog;
|
|
||||||
using Topshelf;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host
|
|
||||||
{
|
|
||||||
internal static class Program
|
|
||||||
{
|
|
||||||
private static int Main(string[] args)
|
|
||||||
{
|
|
||||||
// Set working directory to exe location so relative log paths resolve correctly
|
|
||||||
// (Windows services default to System32)
|
|
||||||
Environment.CurrentDirectory = AppDomain.CurrentDomain.BaseDirectory;
|
|
||||||
|
|
||||||
Log.Logger = new LoggerConfiguration()
|
|
||||||
.MinimumLevel.Information()
|
|
||||||
.WriteTo.Console()
|
|
||||||
.WriteTo.File(
|
|
||||||
"logs/lmxopcua-.log",
|
|
||||||
rollingInterval: RollingInterval.Day,
|
|
||||||
retainedFileCountLimit: 31)
|
|
||||||
.CreateLogger();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var exitCode = HostFactory.Run(host =>
|
|
||||||
{
|
|
||||||
host.UseSerilog();
|
|
||||||
|
|
||||||
host.Service<OpcUaService>(svc =>
|
|
||||||
{
|
|
||||||
svc.ConstructUsing(() => new OpcUaService());
|
|
||||||
svc.WhenStarted(s => s.Start());
|
|
||||||
svc.WhenStopped(s => s.Stop());
|
|
||||||
});
|
|
||||||
|
|
||||||
host.SetServiceName("OtOpcUa");
|
|
||||||
host.SetDisplayName("LMX OPC UA Server");
|
|
||||||
host.SetDescription("OPC UA server exposing System Platform Galaxy tags via MXAccess.");
|
|
||||||
host.RunAsLocalSystem();
|
|
||||||
host.StartAutomatically();
|
|
||||||
});
|
|
||||||
|
|
||||||
return (int)exitCode;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Fatal(ex, "Host terminated unexpectedly");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Log.CloseAndFlush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
using System.Linq;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Status
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Determines health status based on connection state and operation success rates. (DASH-003)
|
|
||||||
/// </summary>
|
|
||||||
public class HealthCheckService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Evaluates bridge health from runtime connectivity, recorded performance metrics, and optional
|
|
||||||
/// historian/alarm integration state.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="connectionState">The current MXAccess connection state.</param>
|
|
||||||
/// <param name="metrics">The recorded performance metrics, if available.</param>
|
|
||||||
/// <param name="historian">Optional historian integration snapshot; pass <c>null</c> to skip historian health rules.</param>
|
|
||||||
/// <param name="alarms">Optional alarm integration snapshot; pass <c>null</c> to skip alarm health rules.</param>
|
|
||||||
/// <returns>A dashboard health snapshot describing the current service condition.</returns>
|
|
||||||
public HealthInfo CheckHealth(
|
|
||||||
ConnectionState connectionState,
|
|
||||||
PerformanceMetrics? metrics,
|
|
||||||
HistorianStatusInfo? historian = null,
|
|
||||||
AlarmStatusInfo? alarms = null,
|
|
||||||
RuntimeStatusInfo? runtime = null)
|
|
||||||
{
|
|
||||||
// Rule 1: Not connected → Unhealthy
|
|
||||||
if (connectionState != ConnectionState.Connected)
|
|
||||||
return new HealthInfo
|
|
||||||
{
|
|
||||||
Status = "Unhealthy",
|
|
||||||
Message = $"MXAccess not connected (state: {connectionState})",
|
|
||||||
Color = "red"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Rule 2b: Historian enabled but plugin did not load → Degraded
|
|
||||||
if (historian != null && historian.Enabled && historian.PluginStatus != "Loaded")
|
|
||||||
return new HealthInfo
|
|
||||||
{
|
|
||||||
Status = "Degraded",
|
|
||||||
Message =
|
|
||||||
$"Historian enabled but plugin status is {historian.PluginStatus}: {historian.PluginError ?? "(no error)"}",
|
|
||||||
Color = "yellow"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Rule 2b2: Historian plugin loaded but queries are failing consecutively → Degraded.
|
|
||||||
// Threshold of 3 avoids flagging a single transient blip; anything beyond that means
|
|
||||||
// the SDK is in a broken state that the reconnect loop isn't recovering from.
|
|
||||||
if (historian != null && historian.Enabled && historian.PluginStatus == "Loaded"
|
|
||||||
&& historian.ConsecutiveFailures >= 3)
|
|
||||||
return new HealthInfo
|
|
||||||
{
|
|
||||||
Status = "Degraded",
|
|
||||||
Message =
|
|
||||||
$"Historian plugin has {historian.ConsecutiveFailures} consecutive query failures: " +
|
|
||||||
$"{historian.LastQueryError ?? "(no error)"}",
|
|
||||||
Color = "yellow"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Rule 2b3: Historian cluster has nodes in cooldown → Degraded (partial cluster).
|
|
||||||
// Only surfaces when the operator actually configured a multi-node cluster.
|
|
||||||
if (historian != null && historian.Enabled && historian.PluginStatus == "Loaded"
|
|
||||||
&& historian.NodeCount > 1 && historian.HealthyNodeCount < historian.NodeCount)
|
|
||||||
return new HealthInfo
|
|
||||||
{
|
|
||||||
Status = "Degraded",
|
|
||||||
Message =
|
|
||||||
$"Historian cluster has {historian.HealthyNodeCount} of {historian.NodeCount} " +
|
|
||||||
"nodes healthy — one or more nodes are in failure cooldown",
|
|
||||||
Color = "yellow"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Rule 2 / 2c: Success rate too low for any recorded operation
|
|
||||||
if (metrics != null)
|
|
||||||
{
|
|
||||||
var stats = metrics.GetStatistics();
|
|
||||||
foreach (var kvp in stats)
|
|
||||||
{
|
|
||||||
var isHistoryOp = kvp.Key.StartsWith("HistoryRead", System.StringComparison.OrdinalIgnoreCase);
|
|
||||||
// History reads are rare; drop the sample threshold so a stuck historian surfaces quickly.
|
|
||||||
var sampleThreshold = isHistoryOp ? 10 : 100;
|
|
||||||
if (kvp.Value.TotalCount > sampleThreshold && kvp.Value.SuccessRate < 0.5)
|
|
||||||
return new HealthInfo
|
|
||||||
{
|
|
||||||
Status = "Degraded",
|
|
||||||
Message =
|
|
||||||
$"{kvp.Key} success rate is {kvp.Value.SuccessRate:P0} ({kvp.Value.TotalCount} ops)",
|
|
||||||
Color = "yellow"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rule 2d: Any alarm acknowledge write has failed since startup → Degraded (latched)
|
|
||||||
if (alarms != null && alarms.TrackingEnabled && alarms.AckWriteFailures > 0)
|
|
||||||
return new HealthInfo
|
|
||||||
{
|
|
||||||
Status = "Degraded",
|
|
||||||
Message = $"Alarm acknowledge writes have failed ({alarms.AckWriteFailures} total)",
|
|
||||||
Color = "yellow"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Rule 2e: Any Galaxy runtime host (Platform/AppEngine) is Stopped → Degraded.
|
|
||||||
// Runs after the transport check so that MxAccess-disconnected remains Unhealthy via
|
|
||||||
// Rule 1 without also firing the runtime rule — avoids a double-message when the
|
|
||||||
// transport is the root cause of every host going Unknown/Stopped.
|
|
||||||
if (runtime != null && runtime.StoppedCount > 0)
|
|
||||||
{
|
|
||||||
var stoppedNames = string.Join(", ",
|
|
||||||
runtime.Hosts.Where(h => h.State == Domain.GalaxyRuntimeState.Stopped).Select(h => h.ObjectName));
|
|
||||||
return new HealthInfo
|
|
||||||
{
|
|
||||||
Status = "Degraded",
|
|
||||||
Message =
|
|
||||||
$"Galaxy runtime has {runtime.StoppedCount} of {runtime.Total} host(s) stopped: {stoppedNames}",
|
|
||||||
Color = "yellow"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rule 3: All good
|
|
||||||
return new HealthInfo
|
|
||||||
{
|
|
||||||
Status = "Healthy",
|
|
||||||
Message = "All systems operational",
|
|
||||||
Color = "green"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Determines whether the bridge should currently be treated as healthy.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="connectionState">The current MXAccess connection state.</param>
|
|
||||||
/// <param name="metrics">The recorded performance metrics, if available.</param>
|
|
||||||
/// <returns><see langword="true" /> when the bridge is not unhealthy; otherwise, <see langword="false" />.</returns>
|
|
||||||
public bool IsHealthy(ConnectionState connectionState, PerformanceMetrics? metrics)
|
|
||||||
{
|
|
||||||
var health = CheckHealth(connectionState, metrics);
|
|
||||||
return health.Status != "Unhealthy";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,570 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Status
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// DTO containing all dashboard data. (DASH-001 through DASH-009)
|
|
||||||
/// </summary>
|
|
||||||
public class StatusData
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the current MXAccess and service connectivity summary shown on the dashboard.
|
|
||||||
/// </summary>
|
|
||||||
public ConnectionInfo Connection { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the overall health state communicated to operators.
|
|
||||||
/// </summary>
|
|
||||||
public HealthInfo Health { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets subscription counts that show how many live tag streams the bridge is maintaining.
|
|
||||||
/// </summary>
|
|
||||||
public SubscriptionInfo Subscriptions { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets Galaxy-specific metadata such as deploy timing and address-space counts.
|
|
||||||
/// </summary>
|
|
||||||
public GalaxyInfo Galaxy { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets MXAccess data change dispatch queue metrics.
|
|
||||||
/// </summary>
|
|
||||||
public DataChangeInfo DataChange { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets per-operation performance statistics used to diagnose bridge throughput and latency.
|
|
||||||
/// </summary>
|
|
||||||
public Dictionary<string, MetricsStatistics> Operations { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the historian integration status (plugin load outcome, server target).
|
|
||||||
/// </summary>
|
|
||||||
public HistorianStatusInfo Historian { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the alarm integration status and event counters.
|
|
||||||
/// </summary>
|
|
||||||
public AlarmStatusInfo Alarms { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the redundancy state when redundancy is enabled.
|
|
||||||
/// </summary>
|
|
||||||
public RedundancyInfo? Redundancy { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the listening OPC UA endpoints and active security profiles.
|
|
||||||
/// </summary>
|
|
||||||
public EndpointsInfo Endpoints { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy runtime host state (Platforms + AppEngines).
|
|
||||||
/// </summary>
|
|
||||||
public RuntimeStatusInfo RuntimeStatus { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets footer details such as the snapshot timestamp and service version.
|
|
||||||
/// </summary>
|
|
||||||
public FooterInfo Footer { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dashboard model summarizing per-host Galaxy runtime state.
|
|
||||||
/// </summary>
|
|
||||||
public class RuntimeStatusInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the total number of tracked runtime hosts ($WinPlatform + $AppEngine).
|
|
||||||
/// </summary>
|
|
||||||
public int Total { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the count of hosts currently reported Running.
|
|
||||||
/// </summary>
|
|
||||||
public int RunningCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the count of hosts currently reported Stopped.
|
|
||||||
/// </summary>
|
|
||||||
public int StoppedCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the count of hosts whose state is still Unknown (either awaiting initial
|
|
||||||
/// probe resolution or transported-through-disconnected).
|
|
||||||
/// </summary>
|
|
||||||
public int UnknownCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the per-host state in stable alphabetical order.
|
|
||||||
/// </summary>
|
|
||||||
public List<GalaxyRuntimeStatus> Hosts { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dashboard model describing the OPC UA server's listening endpoints and active security profiles.
|
|
||||||
/// </summary>
|
|
||||||
public class EndpointsInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the list of opc.tcp base addresses the server is listening on.
|
|
||||||
/// </summary>
|
|
||||||
public List<string> BaseAddresses { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the list of configured user token policies (Anonymous, UserName, Certificate).
|
|
||||||
/// </summary>
|
|
||||||
public List<string> UserTokenPolicies { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the active security profiles reported to clients.
|
|
||||||
/// </summary>
|
|
||||||
public List<SecurityProfileInfo> SecurityProfiles { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dashboard model for a single configured OPC UA server security profile.
|
|
||||||
/// </summary>
|
|
||||||
public class SecurityProfileInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the OPC UA security policy URI (e.g., http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256).
|
|
||||||
/// </summary>
|
|
||||||
public string PolicyUri { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the short policy name extracted from the policy URI.
|
|
||||||
/// </summary>
|
|
||||||
public string PolicyName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the message security mode (None, Sign, SignAndEncrypt).
|
|
||||||
/// </summary>
|
|
||||||
public string SecurityMode { get; set; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dashboard model for current runtime connection details.
|
|
||||||
/// </summary>
|
|
||||||
public class ConnectionInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the current MXAccess connection state shown to operators.
|
|
||||||
/// </summary>
|
|
||||||
public string State { get; set; } = "Disconnected";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets how many reconnect attempts have occurred since the service started.
|
|
||||||
/// </summary>
|
|
||||||
public int ReconnectCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of active OPC UA sessions connected to the bridge.
|
|
||||||
/// </summary>
|
|
||||||
public int ActiveSessions { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dashboard model for the overall health banner.
|
|
||||||
/// </summary>
|
|
||||||
public class HealthInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the high-level health state, such as Healthy, Degraded, or Unhealthy.
|
|
||||||
/// </summary>
|
|
||||||
public string Status { get; set; } = "Unknown";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the operator-facing explanation for the current health state.
|
|
||||||
/// </summary>
|
|
||||||
public string Message { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the color token used by the dashboard UI to render the health banner.
|
|
||||||
/// </summary>
|
|
||||||
public string Color { get; set; } = "gray";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dashboard model for subscription load.
|
|
||||||
/// </summary>
|
|
||||||
public class SubscriptionInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of active tag subscriptions mirrored from MXAccess into OPC UA.
|
|
||||||
/// This total includes bridge-owned runtime status probes; see <see cref="ProbeCount"/> for the
|
|
||||||
/// subset attributable to probes.
|
|
||||||
/// </summary>
|
|
||||||
public int ActiveCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the count of bridge-owned runtime status probes included in
|
|
||||||
/// <see cref="ActiveCount"/>. Surfaced on the dashboard so operators can distinguish probe
|
|
||||||
/// overhead from client-driven subscription load.
|
|
||||||
/// </summary>
|
|
||||||
public int ProbeCount { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dashboard model for Galaxy metadata and rebuild status.
|
|
||||||
/// </summary>
|
|
||||||
public class GalaxyInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the Galaxy name currently being bridged into OPC UA.
|
|
||||||
/// </summary>
|
|
||||||
public string GalaxyName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the repository database is currently reachable.
|
|
||||||
/// </summary>
|
|
||||||
public bool DbConnected { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the most recent deploy timestamp observed in the Galaxy repository.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? LastDeployTime { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of Galaxy objects currently represented in the address space.
|
|
||||||
/// </summary>
|
|
||||||
public int ObjectCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of Galaxy attributes currently represented as OPC UA variables.
|
|
||||||
/// </summary>
|
|
||||||
public int AttributeCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the UTC timestamp of the last completed address-space rebuild.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? LastRebuildTime { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dashboard model for MXAccess data change dispatch metrics.
|
|
||||||
/// </summary>
|
|
||||||
public class DataChangeInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the rate of MXAccess data change events received per second.
|
|
||||||
/// </summary>
|
|
||||||
public double EventsPerSecond { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the average number of items processed per dispatch cycle.
|
|
||||||
/// </summary>
|
|
||||||
public double AvgBatchSize { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of items currently waiting in the dispatch queue.
|
|
||||||
/// </summary>
|
|
||||||
public int PendingItems { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the total MXAccess data change events received since startup.
|
|
||||||
/// </summary>
|
|
||||||
public long TotalEvents { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dashboard model for the Wonderware historian integration (runtime-loaded plugin).
|
|
||||||
/// </summary>
|
|
||||||
public class HistorianStatusInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether historian support is enabled in configuration.
|
|
||||||
/// </summary>
|
|
||||||
public bool Enabled { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the most recent plugin load outcome as a string.
|
|
||||||
/// Values: <c>Disabled</c>, <c>NotFound</c>, <c>LoadFailed</c>, <c>Loaded</c>.
|
|
||||||
/// </summary>
|
|
||||||
public string PluginStatus { get; set; } = "Disabled";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the error message from the last load attempt when <see cref="PluginStatus"/> is <c>LoadFailed</c>.
|
|
||||||
/// </summary>
|
|
||||||
public string? PluginError { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the absolute path the loader probed for the plugin assembly.
|
|
||||||
/// </summary>
|
|
||||||
public string PluginPath { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the configured historian server hostname.
|
|
||||||
/// </summary>
|
|
||||||
public string ServerName { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the configured historian TCP port.
|
|
||||||
/// </summary>
|
|
||||||
public int Port { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the total number of historian read queries attempted since startup.
|
|
||||||
/// </summary>
|
|
||||||
public long QueryTotal { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of historian queries that completed without an exception.
|
|
||||||
/// </summary>
|
|
||||||
public long QuerySuccesses { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of historian queries that raised an exception.
|
|
||||||
/// </summary>
|
|
||||||
public long QueryFailures { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of consecutive failures since the last successful query.
|
|
||||||
/// </summary>
|
|
||||||
public int ConsecutiveFailures { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the UTC timestamp of the last successful query.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? LastSuccessTime { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the UTC timestamp of the last query failure.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? LastFailureTime { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the exception message from the most recent failure.
|
|
||||||
/// </summary>
|
|
||||||
public string? LastQueryError { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the plugin currently holds an open process-path
|
|
||||||
/// SDK connection.
|
|
||||||
/// </summary>
|
|
||||||
public bool ProcessConnectionOpen { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the plugin currently holds an open event-path
|
|
||||||
/// SDK connection.
|
|
||||||
/// </summary>
|
|
||||||
public bool EventConnectionOpen { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the total number of configured historian cluster nodes.
|
|
||||||
/// </summary>
|
|
||||||
public int NodeCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of cluster nodes currently eligible for new connections
|
|
||||||
/// (i.e., not in failure cooldown).
|
|
||||||
/// </summary>
|
|
||||||
public int HealthyNodeCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the node currently serving process (historical value) queries, or null
|
|
||||||
/// when no process connection is open.
|
|
||||||
/// </summary>
|
|
||||||
public string? ActiveProcessNode { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the node currently serving event (alarm history) queries, or null when
|
|
||||||
/// no event connection is open.
|
|
||||||
/// </summary>
|
|
||||||
public string? ActiveEventNode { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the per-node cluster state in configuration order.
|
|
||||||
/// </summary>
|
|
||||||
public List<Historian.HistorianClusterNodeState> Nodes { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dashboard model for alarm integration health and event counters.
|
|
||||||
/// </summary>
|
|
||||||
public class AlarmStatusInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether alarm condition tracking is enabled in configuration.
|
|
||||||
/// </summary>
|
|
||||||
public bool TrackingEnabled { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of distinct alarm conditions currently tracked.
|
|
||||||
/// </summary>
|
|
||||||
public int ConditionCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of alarms currently in the InAlarm=true state.
|
|
||||||
/// </summary>
|
|
||||||
public int ActiveAlarmCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the total number of InAlarm transitions observed since startup.
|
|
||||||
/// </summary>
|
|
||||||
public long TransitionCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the total number of alarm acknowledgement transitions observed since startup.
|
|
||||||
/// </summary>
|
|
||||||
public long AckEventCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the total number of alarm acknowledgement MXAccess writes that have failed since startup.
|
|
||||||
/// </summary>
|
|
||||||
public long AckWriteFailures { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether the template-based alarm object filter is active.
|
|
||||||
/// </summary>
|
|
||||||
public bool FilterEnabled { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of compiled alarm filter patterns.
|
|
||||||
/// </summary>
|
|
||||||
public int FilterPatternCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the number of Galaxy objects included by the alarm filter during the most recent build.
|
|
||||||
/// </summary>
|
|
||||||
public int FilterIncludedObjectCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the raw alarm filter patterns exactly as configured, for dashboard display.
|
|
||||||
/// </summary>
|
|
||||||
public List<string> FilterPatterns { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dashboard model for redundancy state. Only populated when redundancy is enabled.
|
|
||||||
/// </summary>
|
|
||||||
public class RedundancyInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets whether redundancy is enabled.
|
|
||||||
/// </summary>
|
|
||||||
public bool Enabled { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the redundancy mode (e.g., "Warm", "Hot").
|
|
||||||
/// </summary>
|
|
||||||
public string Mode { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets this instance's role ("Primary" or "Secondary").
|
|
||||||
/// </summary>
|
|
||||||
public string Role { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the current ServiceLevel byte.
|
|
||||||
/// </summary>
|
|
||||||
public byte ServiceLevel { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets this instance's ApplicationUri.
|
|
||||||
/// </summary>
|
|
||||||
public string ApplicationUri { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the list of all server URIs in the redundant set.
|
|
||||||
/// </summary>
|
|
||||||
public List<string> ServerUris { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for the /api/health endpoint. Includes component-level health, ServiceLevel, and redundancy state.
|
|
||||||
/// </summary>
|
|
||||||
public class HealthEndpointData
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the overall health status: Healthy, Degraded, or Unhealthy.
|
|
||||||
/// </summary>
|
|
||||||
public string Status { get; set; } = "Unknown";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the computed OPC UA ServiceLevel byte (0-255). Only meaningful when redundancy is enabled.
|
|
||||||
/// </summary>
|
|
||||||
public byte ServiceLevel { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets whether redundancy is enabled.
|
|
||||||
/// </summary>
|
|
||||||
public bool RedundancyEnabled { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets this instance's redundancy role when enabled (Primary/Secondary), or null when disabled.
|
|
||||||
/// </summary>
|
|
||||||
public string? RedundancyRole { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the redundancy mode when enabled (Warm/Hot), or null when disabled.
|
|
||||||
/// </summary>
|
|
||||||
public string? RedundancyMode { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the per-component health breakdown.
|
|
||||||
/// </summary>
|
|
||||||
public ComponentHealth Components { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the server uptime since the health endpoint was initialized.
|
|
||||||
/// </summary>
|
|
||||||
public string Uptime { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the UTC timestamp of this health snapshot.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Per-component health breakdown for the health endpoint.
|
|
||||||
/// </summary>
|
|
||||||
public class ComponentHealth
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets MXAccess runtime connectivity status.
|
|
||||||
/// </summary>
|
|
||||||
public string MxAccess { get; set; } = "Disconnected";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets Galaxy repository database connectivity status.
|
|
||||||
/// </summary>
|
|
||||||
public string Database { get; set; } = "Disconnected";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets OPC UA server status.
|
|
||||||
/// </summary>
|
|
||||||
public string OpcUaServer { get; set; } = "Stopped";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the historian plugin status.
|
|
||||||
/// Values: <c>Disabled</c>, <c>NotFound</c>, <c>LoadFailed</c>, <c>Loaded</c>.
|
|
||||||
/// </summary>
|
|
||||||
public string Historian { get; set; } = "Disabled";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets whether alarm condition tracking is enabled.
|
|
||||||
/// Values: <c>Disabled</c>, <c>Enabled</c>.
|
|
||||||
/// </summary>
|
|
||||||
public string Alarms { get; set; } = "Disabled";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dashboard model for the status page footer.
|
|
||||||
/// </summary>
|
|
||||||
public class FooterInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the UTC time when the status snapshot was generated.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the service version displayed to operators for support and traceability.
|
|
||||||
/// </summary>
|
|
||||||
public string Version { get; set; } = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,644 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Status
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Aggregates status from all components and generates HTML/JSON reports. (DASH-001 through DASH-009)
|
|
||||||
/// </summary>
|
|
||||||
public class StatusReportService
|
|
||||||
{
|
|
||||||
private readonly HealthCheckService _healthCheck;
|
|
||||||
private readonly int _refreshIntervalSeconds;
|
|
||||||
private readonly DateTime _startTime = DateTime.UtcNow;
|
|
||||||
private string? _applicationUri;
|
|
||||||
private GalaxyRepositoryStats? _galaxyStats;
|
|
||||||
private PerformanceMetrics? _metrics;
|
|
||||||
|
|
||||||
private HistorianConfiguration? _historianConfig;
|
|
||||||
private IMxAccessClient? _mxAccessClient;
|
|
||||||
private LmxNodeManager? _nodeManager;
|
|
||||||
private RedundancyConfiguration? _redundancyConfig;
|
|
||||||
private OpcUaServerHost? _serverHost;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new status report service for the dashboard using the supplied health-check policy and refresh
|
|
||||||
/// interval.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="healthCheck">The health-check component used to derive the overall dashboard health status.</param>
|
|
||||||
/// <param name="refreshIntervalSeconds">The HTML auto-refresh interval, in seconds, for the dashboard page.</param>
|
|
||||||
public StatusReportService(HealthCheckService healthCheck, int refreshIntervalSeconds)
|
|
||||||
{
|
|
||||||
_healthCheck = healthCheck;
|
|
||||||
_refreshIntervalSeconds = refreshIntervalSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Supplies the live bridge components whose status should be reflected in generated dashboard snapshots.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="mxAccessClient">The runtime client whose connection and subscription state should be reported.</param>
|
|
||||||
/// <param name="metrics">The performance metrics collector whose operation statistics should be reported.</param>
|
|
||||||
/// <param name="galaxyStats">The Galaxy repository statistics to surface on the dashboard.</param>
|
|
||||||
/// <param name="serverHost">The OPC UA server host whose active session count should be reported.</param>
|
|
||||||
/// <param name="nodeManager">
|
|
||||||
/// The node manager whose queue depth and MXAccess event throughput should be surfaced on the
|
|
||||||
/// dashboard.
|
|
||||||
/// </param>
|
|
||||||
public void SetComponents(IMxAccessClient? mxAccessClient, PerformanceMetrics? metrics,
|
|
||||||
GalaxyRepositoryStats? galaxyStats, OpcUaServerHost? serverHost,
|
|
||||||
LmxNodeManager? nodeManager = null,
|
|
||||||
RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null,
|
|
||||||
HistorianConfiguration? historianConfig = null)
|
|
||||||
{
|
|
||||||
_mxAccessClient = mxAccessClient;
|
|
||||||
_metrics = metrics;
|
|
||||||
_galaxyStats = galaxyStats;
|
|
||||||
_serverHost = serverHost;
|
|
||||||
_nodeManager = nodeManager;
|
|
||||||
_redundancyConfig = redundancyConfig;
|
|
||||||
_applicationUri = applicationUri;
|
|
||||||
_historianConfig = historianConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the structured dashboard snapshot consumed by the HTML and JSON renderers.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>The current dashboard status data for the bridge.</returns>
|
|
||||||
public StatusData GetStatusData()
|
|
||||||
{
|
|
||||||
var connectionState = _mxAccessClient?.State ?? ConnectionState.Disconnected;
|
|
||||||
var historianInfo = BuildHistorianStatusInfo();
|
|
||||||
var alarmInfo = BuildAlarmStatusInfo();
|
|
||||||
|
|
||||||
return new StatusData
|
|
||||||
{
|
|
||||||
Connection = new ConnectionInfo
|
|
||||||
{
|
|
||||||
State = connectionState.ToString(),
|
|
||||||
ReconnectCount = _mxAccessClient?.ReconnectCount ?? 0,
|
|
||||||
ActiveSessions = _serverHost?.ActiveSessionCount ?? 0
|
|
||||||
},
|
|
||||||
Health = _healthCheck.CheckHealth(connectionState, _metrics, historianInfo, alarmInfo, BuildRuntimeStatusInfo()),
|
|
||||||
Subscriptions = new SubscriptionInfo
|
|
||||||
{
|
|
||||||
ActiveCount = _mxAccessClient?.ActiveSubscriptionCount ?? 0,
|
|
||||||
ProbeCount = _nodeManager?.ActiveRuntimeProbeCount ?? 0
|
|
||||||
},
|
|
||||||
Galaxy = new GalaxyInfo
|
|
||||||
{
|
|
||||||
GalaxyName = _galaxyStats?.GalaxyName ?? "",
|
|
||||||
DbConnected = _galaxyStats?.DbConnected ?? false,
|
|
||||||
LastDeployTime = _galaxyStats?.LastDeployTime,
|
|
||||||
ObjectCount = _galaxyStats?.ObjectCount ?? 0,
|
|
||||||
AttributeCount = _galaxyStats?.AttributeCount ?? 0,
|
|
||||||
LastRebuildTime = _galaxyStats?.LastRebuildTime
|
|
||||||
},
|
|
||||||
DataChange = new DataChangeInfo
|
|
||||||
{
|
|
||||||
EventsPerSecond = _nodeManager?.MxChangeEventsPerSecond ?? 0,
|
|
||||||
AvgBatchSize = _nodeManager?.AverageDispatchBatchSize ?? 0,
|
|
||||||
PendingItems = _nodeManager?.PendingDataChangeCount ?? 0,
|
|
||||||
TotalEvents = _nodeManager?.TotalMxChangeEvents ?? 0
|
|
||||||
},
|
|
||||||
Operations = _metrics?.GetStatistics() ?? new Dictionary<string, MetricsStatistics>(),
|
|
||||||
Historian = historianInfo,
|
|
||||||
Alarms = alarmInfo,
|
|
||||||
Redundancy = BuildRedundancyInfo(),
|
|
||||||
Endpoints = BuildEndpointsInfo(),
|
|
||||||
RuntimeStatus = BuildRuntimeStatusInfo(),
|
|
||||||
Footer = new FooterInfo
|
|
||||||
{
|
|
||||||
Timestamp = DateTime.UtcNow,
|
|
||||||
Version = typeof(StatusReportService).Assembly.GetName().Version?.ToString() ?? "1.0.0"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private HistorianStatusInfo BuildHistorianStatusInfo()
|
|
||||||
{
|
|
||||||
var outcome = HistorianPluginLoader.LastOutcome;
|
|
||||||
var health = _nodeManager?.HistorianHealth;
|
|
||||||
return new HistorianStatusInfo
|
|
||||||
{
|
|
||||||
Enabled = _historianConfig?.Enabled ?? false,
|
|
||||||
PluginStatus = outcome.Status.ToString(),
|
|
||||||
PluginError = outcome.Error,
|
|
||||||
PluginPath = outcome.PluginPath,
|
|
||||||
ServerName = _historianConfig?.ServerName ?? "",
|
|
||||||
Port = _historianConfig?.Port ?? 0,
|
|
||||||
QueryTotal = health?.TotalQueries ?? 0,
|
|
||||||
QuerySuccesses = health?.TotalSuccesses ?? 0,
|
|
||||||
QueryFailures = health?.TotalFailures ?? 0,
|
|
||||||
ConsecutiveFailures = health?.ConsecutiveFailures ?? 0,
|
|
||||||
LastSuccessTime = health?.LastSuccessTime,
|
|
||||||
LastFailureTime = health?.LastFailureTime,
|
|
||||||
LastQueryError = health?.LastError,
|
|
||||||
ProcessConnectionOpen = health?.ProcessConnectionOpen ?? false,
|
|
||||||
EventConnectionOpen = health?.EventConnectionOpen ?? false,
|
|
||||||
NodeCount = health?.NodeCount ?? 0,
|
|
||||||
HealthyNodeCount = health?.HealthyNodeCount ?? 0,
|
|
||||||
ActiveProcessNode = health?.ActiveProcessNode,
|
|
||||||
ActiveEventNode = health?.ActiveEventNode,
|
|
||||||
Nodes = health?.Nodes ?? new List<Historian.HistorianClusterNodeState>()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private AlarmStatusInfo BuildAlarmStatusInfo()
|
|
||||||
{
|
|
||||||
return new AlarmStatusInfo
|
|
||||||
{
|
|
||||||
TrackingEnabled = _nodeManager?.AlarmTrackingEnabled ?? false,
|
|
||||||
ConditionCount = _nodeManager?.AlarmConditionCount ?? 0,
|
|
||||||
ActiveAlarmCount = _nodeManager?.ActiveAlarmCount ?? 0,
|
|
||||||
TransitionCount = _nodeManager?.AlarmTransitionCount ?? 0,
|
|
||||||
AckEventCount = _nodeManager?.AlarmAckEventCount ?? 0,
|
|
||||||
AckWriteFailures = _nodeManager?.AlarmAckWriteFailures ?? 0,
|
|
||||||
FilterEnabled = _nodeManager?.AlarmFilterEnabled ?? false,
|
|
||||||
FilterPatternCount = _nodeManager?.AlarmFilterPatternCount ?? 0,
|
|
||||||
FilterIncludedObjectCount = _nodeManager?.AlarmFilterIncludedObjectCount ?? 0,
|
|
||||||
FilterPatterns = _nodeManager?.AlarmFilterPatterns?.ToList() ?? new List<string>()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private EndpointsInfo BuildEndpointsInfo()
|
|
||||||
{
|
|
||||||
var info = new EndpointsInfo();
|
|
||||||
if (_serverHost == null)
|
|
||||||
return info;
|
|
||||||
|
|
||||||
info.BaseAddresses = _serverHost.BaseAddresses.ToList();
|
|
||||||
info.UserTokenPolicies = _serverHost.UserTokenPolicies.Distinct().ToList();
|
|
||||||
foreach (var policy in _serverHost.SecurityPolicies)
|
|
||||||
{
|
|
||||||
var uri = policy.SecurityPolicyUri ?? "";
|
|
||||||
var hashIdx = uri.LastIndexOf('#');
|
|
||||||
var name = hashIdx >= 0 && hashIdx < uri.Length - 1 ? uri.Substring(hashIdx + 1) : uri;
|
|
||||||
info.SecurityProfiles.Add(new SecurityProfileInfo
|
|
||||||
{
|
|
||||||
PolicyUri = uri,
|
|
||||||
PolicyName = name,
|
|
||||||
SecurityMode = policy.SecurityMode.ToString()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
private RuntimeStatusInfo BuildRuntimeStatusInfo()
|
|
||||||
{
|
|
||||||
var hosts = _nodeManager?.RuntimeStatuses?.ToList() ?? new List<GalaxyRuntimeStatus>();
|
|
||||||
var info = new RuntimeStatusInfo
|
|
||||||
{
|
|
||||||
Total = hosts.Count,
|
|
||||||
Hosts = hosts
|
|
||||||
};
|
|
||||||
foreach (var host in hosts)
|
|
||||||
{
|
|
||||||
switch (host.State)
|
|
||||||
{
|
|
||||||
case GalaxyRuntimeState.Running: info.RunningCount++; break;
|
|
||||||
case GalaxyRuntimeState.Stopped: info.StoppedCount++; break;
|
|
||||||
default: info.UnknownCount++; break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
private RedundancyInfo? BuildRedundancyInfo()
|
|
||||||
{
|
|
||||||
if (_redundancyConfig == null || !_redundancyConfig.Enabled)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var mxConnected = (_mxAccessClient?.State ?? ConnectionState.Disconnected) == ConnectionState.Connected;
|
|
||||||
var dbConnected = _galaxyStats?.DbConnected ?? false;
|
|
||||||
var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase);
|
|
||||||
var baseLevel = isPrimary
|
|
||||||
? _redundancyConfig.ServiceLevelBase
|
|
||||||
: Math.Max(0, _redundancyConfig.ServiceLevelBase - 50);
|
|
||||||
var calculator = new ServiceLevelCalculator();
|
|
||||||
|
|
||||||
return new RedundancyInfo
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
Mode = _redundancyConfig.Mode,
|
|
||||||
Role = _redundancyConfig.Role,
|
|
||||||
ServiceLevel = calculator.Calculate(baseLevel, mxConnected, dbConnected),
|
|
||||||
ApplicationUri = _applicationUri ?? "",
|
|
||||||
ServerUris = new List<string>(_redundancyConfig.ServerUris)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates the operator-facing HTML dashboard for the current bridge status.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>An HTML document containing the latest dashboard snapshot.</returns>
|
|
||||||
public string GenerateHtml()
|
|
||||||
{
|
|
||||||
var data = GetStatusData();
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
|
|
||||||
sb.AppendLine("<!DOCTYPE html><html><head>");
|
|
||||||
sb.AppendLine("<meta charset='utf-8'>");
|
|
||||||
sb.AppendLine($"<meta http-equiv='refresh' content='{_refreshIntervalSeconds}'>");
|
|
||||||
sb.AppendLine("<title>LmxOpcUa Status</title>");
|
|
||||||
sb.AppendLine("<style>");
|
|
||||||
sb.AppendLine("body { font-family: monospace; background: #1a1a2e; color: #eee; padding: 20px; }");
|
|
||||||
sb.AppendLine(".panel { border: 2px solid #444; border-radius: 8px; padding: 15px; margin: 10px 0; }");
|
|
||||||
sb.AppendLine(
|
|
||||||
".green { border-color: #00cc66; } .red { border-color: #cc3333; } .yellow { border-color: #cccc33; } .gray { border-color: #666; }");
|
|
||||||
sb.AppendLine(
|
|
||||||
"table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #333; }");
|
|
||||||
sb.AppendLine("h2 { margin: 0 0 10px 0; } h1 { color: #66ccff; }");
|
|
||||||
sb.AppendLine("h1 .version { color: #888; font-size: 0.5em; font-weight: normal; margin-left: 12px; }");
|
|
||||||
sb.AppendLine("</style></head><body>");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<h1>LmxOpcUa Status Dashboard<span class='version'>v{WebUtility.HtmlEncode(data.Footer.Version)}</span></h1>");
|
|
||||||
|
|
||||||
// Connection panel
|
|
||||||
var connColor = data.Connection.State == "Connected" ? "green" :
|
|
||||||
data.Connection.State == "Connecting" ? "yellow" : "red";
|
|
||||||
sb.AppendLine($"<div class='panel {connColor}'><h2>Connection</h2>");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<p>State: <b>{data.Connection.State}</b> | Reconnects: {data.Connection.ReconnectCount} | Sessions: {data.Connection.ActiveSessions}</p>");
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
// Health panel
|
|
||||||
sb.AppendLine($"<div class='panel {data.Health.Color}'><h2>Health</h2>");
|
|
||||||
sb.AppendLine($"<p>Status: <b>{data.Health.Status}</b> — {data.Health.Message}</p>");
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
// Endpoints panel (exposed URLs + security profiles)
|
|
||||||
var endpointsColor = data.Endpoints.BaseAddresses.Count > 0 ? "green" : "gray";
|
|
||||||
sb.AppendLine($"<div class='panel {endpointsColor}'><h2>Endpoints</h2>");
|
|
||||||
if (data.Endpoints.BaseAddresses.Count == 0)
|
|
||||||
{
|
|
||||||
sb.AppendLine("<p>No endpoints — OPC UA server not started.</p>");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
sb.AppendLine("<p><b>Base Addresses:</b></p><ul>");
|
|
||||||
foreach (var addr in data.Endpoints.BaseAddresses)
|
|
||||||
sb.AppendLine($"<li>{WebUtility.HtmlEncode(addr)}</li>");
|
|
||||||
sb.AppendLine("</ul>");
|
|
||||||
|
|
||||||
sb.AppendLine("<p><b>Security Profiles:</b></p>");
|
|
||||||
sb.AppendLine("<table><tr><th>Mode</th><th>Policy</th><th>Policy URI</th></tr>");
|
|
||||||
foreach (var profile in data.Endpoints.SecurityProfiles)
|
|
||||||
{
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<tr><td>{WebUtility.HtmlEncode(profile.SecurityMode)}</td>" +
|
|
||||||
$"<td>{WebUtility.HtmlEncode(profile.PolicyName)}</td>" +
|
|
||||||
$"<td>{WebUtility.HtmlEncode(profile.PolicyUri)}</td></tr>");
|
|
||||||
}
|
|
||||||
sb.AppendLine("</table>");
|
|
||||||
|
|
||||||
if (data.Endpoints.UserTokenPolicies.Count > 0)
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<p><b>User Token Policies:</b> {WebUtility.HtmlEncode(string.Join(", ", data.Endpoints.UserTokenPolicies))}</p>");
|
|
||||||
}
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
// Redundancy panel (only when enabled)
|
|
||||||
if (data.Redundancy != null)
|
|
||||||
{
|
|
||||||
var roleColor = data.Redundancy.Role == "Primary" ? "green" : "yellow";
|
|
||||||
sb.AppendLine($"<div class='panel {roleColor}'><h2>Redundancy</h2>");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<p>Mode: <b>{data.Redundancy.Mode}</b> | Role: <b>{data.Redundancy.Role}</b> | Service Level: <b>{data.Redundancy.ServiceLevel}</b></p>");
|
|
||||||
sb.AppendLine($"<p>Application URI: {data.Redundancy.ApplicationUri}</p>");
|
|
||||||
sb.AppendLine($"<p>Redundant Set: {string.Join(", ", data.Redundancy.ServerUris)}</p>");
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscriptions panel
|
|
||||||
sb.AppendLine("<div class='panel gray'><h2>Subscriptions</h2>");
|
|
||||||
sb.AppendLine($"<p>Active: <b>{data.Subscriptions.ActiveCount}</b></p>");
|
|
||||||
if (data.Subscriptions.ProbeCount > 0)
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<p>Probes: {data.Subscriptions.ProbeCount} (bridge-owned runtime status)</p>");
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
// Data Change Dispatch panel
|
|
||||||
sb.AppendLine("<div class='panel gray'><h2>Data Change Dispatch</h2>");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<p>Events/sec: <b>{data.DataChange.EventsPerSecond:F1}</b> | Avg Batch Size: <b>{data.DataChange.AvgBatchSize:F1}</b> | Pending: {data.DataChange.PendingItems} | Total Events: {data.DataChange.TotalEvents:N0}</p>");
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
// Galaxy Info panel
|
|
||||||
sb.AppendLine("<div class='panel gray'><h2>Galaxy Info</h2>");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<p>Galaxy: <b>{data.Galaxy.GalaxyName}</b> | DB: {(data.Galaxy.DbConnected ? "Connected" : "Disconnected")}</p>");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<p>Last Deploy: {data.Galaxy.LastDeployTime:O} | Objects: {data.Galaxy.ObjectCount} | Attributes: {data.Galaxy.AttributeCount}</p>");
|
|
||||||
sb.AppendLine($"<p>Last Rebuild: {data.Galaxy.LastRebuildTime:O}</p>");
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
// Galaxy Runtime panel — per-host Platform + AppEngine state
|
|
||||||
if (data.RuntimeStatus.Total > 0)
|
|
||||||
{
|
|
||||||
var rtColor = data.RuntimeStatus.StoppedCount > 0 ? "red"
|
|
||||||
: data.RuntimeStatus.UnknownCount > 0 ? "yellow"
|
|
||||||
: "green";
|
|
||||||
sb.AppendLine($"<div class='panel {rtColor}'><h2>Galaxy Runtime</h2>");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<p>{data.RuntimeStatus.RunningCount} of {data.RuntimeStatus.Total} hosts running" +
|
|
||||||
$" ({data.RuntimeStatus.StoppedCount} stopped, {data.RuntimeStatus.UnknownCount} unknown)</p>");
|
|
||||||
sb.AppendLine("<table><tr><th>Name</th><th>Kind</th><th>State</th><th>Since</th><th>Last Error</th></tr>");
|
|
||||||
foreach (var host in data.RuntimeStatus.Hosts)
|
|
||||||
{
|
|
||||||
var since = host.LastStateChangeTime?.ToString("O") ?? "-";
|
|
||||||
var err = WebUtility.HtmlEncode(host.LastError ?? "");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<tr><td>{WebUtility.HtmlEncode(host.ObjectName)}</td>" +
|
|
||||||
$"<td>{WebUtility.HtmlEncode(host.Kind)}</td>" +
|
|
||||||
$"<td>{host.State}</td>" +
|
|
||||||
$"<td>{since}</td>" +
|
|
||||||
$"<td><code>{err}</code></td></tr>");
|
|
||||||
}
|
|
||||||
sb.AppendLine("</table>");
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Historian panel
|
|
||||||
var anyClusterNodeFailed =
|
|
||||||
data.Historian.NodeCount > 0 && data.Historian.HealthyNodeCount < data.Historian.NodeCount;
|
|
||||||
var allClusterNodesFailed =
|
|
||||||
data.Historian.NodeCount > 0 && data.Historian.HealthyNodeCount == 0;
|
|
||||||
var histColor = !data.Historian.Enabled ? "gray"
|
|
||||||
: data.Historian.PluginStatus != "Loaded" ? "red"
|
|
||||||
: allClusterNodesFailed ? "red"
|
|
||||||
: data.Historian.ConsecutiveFailures >= 5 ? "red"
|
|
||||||
: anyClusterNodeFailed || data.Historian.ConsecutiveFailures > 0 ? "yellow"
|
|
||||||
: "green";
|
|
||||||
sb.AppendLine($"<div class='panel {histColor}'><h2>Historian</h2>");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<p>Enabled: <b>{data.Historian.Enabled}</b> | Plugin: <b>{data.Historian.PluginStatus}</b> | Port: {data.Historian.Port}</p>");
|
|
||||||
if (!string.IsNullOrEmpty(data.Historian.PluginError))
|
|
||||||
sb.AppendLine($"<p>Plugin Error: {WebUtility.HtmlEncode(data.Historian.PluginError)}</p>");
|
|
||||||
if (data.Historian.PluginStatus == "Loaded")
|
|
||||||
{
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<p>Queries: <b>{data.Historian.QueryTotal:N0}</b> " +
|
|
||||||
$"(Success: {data.Historian.QuerySuccesses:N0}, Failure: {data.Historian.QueryFailures:N0}) " +
|
|
||||||
$"| Consecutive Failures: <b>{data.Historian.ConsecutiveFailures}</b></p>");
|
|
||||||
var procBadge = data.Historian.ProcessConnectionOpen
|
|
||||||
? $"open ({WebUtility.HtmlEncode(data.Historian.ActiveProcessNode ?? "?")})"
|
|
||||||
: "closed";
|
|
||||||
var evtBadge = data.Historian.EventConnectionOpen
|
|
||||||
? $"open ({WebUtility.HtmlEncode(data.Historian.ActiveEventNode ?? "?")})"
|
|
||||||
: "closed";
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<p>Process Conn: <b>{procBadge}</b> | Event Conn: <b>{evtBadge}</b></p>");
|
|
||||||
if (data.Historian.LastSuccessTime.HasValue)
|
|
||||||
sb.AppendLine($"<p>Last Success: {data.Historian.LastSuccessTime:O}</p>");
|
|
||||||
if (data.Historian.LastFailureTime.HasValue)
|
|
||||||
sb.AppendLine($"<p>Last Failure: {data.Historian.LastFailureTime:O}</p>");
|
|
||||||
if (!string.IsNullOrEmpty(data.Historian.LastQueryError))
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<p>Last Error: <code>{WebUtility.HtmlEncode(data.Historian.LastQueryError)}</code></p>");
|
|
||||||
|
|
||||||
// Cluster table: only when a true multi-node cluster is configured.
|
|
||||||
if (data.Historian.NodeCount > 1)
|
|
||||||
{
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<p><b>Cluster:</b> {data.Historian.HealthyNodeCount} of {data.Historian.NodeCount} nodes healthy</p>");
|
|
||||||
sb.AppendLine(
|
|
||||||
"<table><tr><th>Node</th><th>State</th><th>Cooldown Until</th><th>Failures</th><th>Last Error</th></tr>");
|
|
||||||
foreach (var node in data.Historian.Nodes)
|
|
||||||
{
|
|
||||||
var state = node.IsHealthy ? "healthy" : "cooldown";
|
|
||||||
var cooldown = node.CooldownUntil?.ToString("O") ?? "-";
|
|
||||||
var lastErr = WebUtility.HtmlEncode(node.LastError ?? "");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<tr><td>{WebUtility.HtmlEncode(node.Name)}</td><td>{state}</td>" +
|
|
||||||
$"<td>{cooldown}</td><td>{node.FailureCount}</td><td><code>{lastErr}</code></td></tr>");
|
|
||||||
}
|
|
||||||
sb.AppendLine("</table>");
|
|
||||||
}
|
|
||||||
else if (data.Historian.NodeCount == 1)
|
|
||||||
{
|
|
||||||
sb.AppendLine($"<p>Node: {WebUtility.HtmlEncode(data.Historian.Nodes[0].Name)}</p>");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
// Alarms panel
|
|
||||||
var alarmPanelColor = !data.Alarms.TrackingEnabled ? "gray"
|
|
||||||
: data.Alarms.AckWriteFailures > 0 ? "yellow" : "green";
|
|
||||||
sb.AppendLine($"<div class='panel {alarmPanelColor}'><h2>Alarms</h2>");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<p>Tracking: <b>{data.Alarms.TrackingEnabled}</b> | Conditions: {data.Alarms.ConditionCount} | Active: <b>{data.Alarms.ActiveAlarmCount}</b></p>");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<p>Transitions: {data.Alarms.TransitionCount:N0} | Ack Events: {data.Alarms.AckEventCount:N0} | Ack Write Failures: {data.Alarms.AckWriteFailures}</p>");
|
|
||||||
if (data.Alarms.FilterEnabled)
|
|
||||||
{
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<p>Filter: <b>{data.Alarms.FilterPatternCount}</b> pattern(s), <b>{data.Alarms.FilterIncludedObjectCount}</b> object(s) included</p>");
|
|
||||||
if (data.Alarms.FilterPatterns.Count > 0)
|
|
||||||
{
|
|
||||||
sb.AppendLine("<ul>");
|
|
||||||
foreach (var pattern in data.Alarms.FilterPatterns)
|
|
||||||
sb.AppendLine($"<li><code>{WebUtility.HtmlEncode(pattern)}</code></li>");
|
|
||||||
sb.AppendLine("</ul>");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
sb.AppendLine("<p>Filter: <b>disabled</b> (all alarm-bearing objects tracked)</p>");
|
|
||||||
}
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
// Operations table
|
|
||||||
sb.AppendLine("<div class='panel gray'><h2>Operations</h2>");
|
|
||||||
sb.AppendLine(
|
|
||||||
"<table><tr><th>Operation</th><th>Count</th><th>Success Rate</th><th>Avg (ms)</th><th>Min (ms)</th><th>Max (ms)</th><th>P95 (ms)</th></tr>");
|
|
||||||
foreach (var kvp in data.Operations)
|
|
||||||
{
|
|
||||||
var s = kvp.Value;
|
|
||||||
sb.AppendLine($"<tr><td>{kvp.Key}</td><td>{s.TotalCount}</td><td>{s.SuccessRate:P1}</td>" +
|
|
||||||
$"<td>{s.AverageMilliseconds:F1}</td><td>{s.MinMilliseconds:F1}</td><td>{s.MaxMilliseconds:F1}</td><td>{s.Percentile95Milliseconds:F1}</td></tr>");
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.AppendLine("</table></div>");
|
|
||||||
|
|
||||||
sb.AppendLine("</body></html>");
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates an indented JSON status payload for API consumers.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A JSON representation of the current dashboard snapshot.</returns>
|
|
||||||
public string GenerateJson()
|
|
||||||
{
|
|
||||||
var data = GetStatusData();
|
|
||||||
return JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Determines whether the bridge should currently be considered healthy for the dashboard health endpoint.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns><see langword="true" /> when the bridge meets the health policy; otherwise, <see langword="false" />.</returns>
|
|
||||||
public bool IsHealthy()
|
|
||||||
{
|
|
||||||
var state = _mxAccessClient?.State ?? ConnectionState.Disconnected;
|
|
||||||
return _healthCheck.IsHealthy(state, _metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the rich health endpoint data including component health, ServiceLevel, and redundancy state.
|
|
||||||
/// </summary>
|
|
||||||
public HealthEndpointData GetHealthData()
|
|
||||||
{
|
|
||||||
var connectionState = _mxAccessClient?.State ?? ConnectionState.Disconnected;
|
|
||||||
var mxConnected = connectionState == ConnectionState.Connected;
|
|
||||||
var dbConnected = _galaxyStats?.DbConnected ?? false;
|
|
||||||
var historianInfo = BuildHistorianStatusInfo();
|
|
||||||
var alarmInfo = BuildAlarmStatusInfo();
|
|
||||||
var health = _healthCheck.CheckHealth(connectionState, _metrics, historianInfo, alarmInfo);
|
|
||||||
var uptime = DateTime.UtcNow - _startTime;
|
|
||||||
|
|
||||||
var data = new HealthEndpointData
|
|
||||||
{
|
|
||||||
Status = health.Status,
|
|
||||||
RedundancyEnabled = _redundancyConfig?.Enabled ?? false,
|
|
||||||
Components = new ComponentHealth
|
|
||||||
{
|
|
||||||
MxAccess = connectionState.ToString(),
|
|
||||||
Database = dbConnected ? "Connected" : "Disconnected",
|
|
||||||
OpcUaServer = _serverHost?.IsRunning ?? false ? "Running" : "Stopped",
|
|
||||||
Historian = historianInfo.PluginStatus,
|
|
||||||
Alarms = alarmInfo.TrackingEnabled ? "Enabled" : "Disabled"
|
|
||||||
},
|
|
||||||
Uptime = FormatUptime(uptime),
|
|
||||||
Timestamp = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
if (_redundancyConfig != null && _redundancyConfig.Enabled)
|
|
||||||
{
|
|
||||||
var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase);
|
|
||||||
var baseLevel = isPrimary
|
|
||||||
? _redundancyConfig.ServiceLevelBase
|
|
||||||
: Math.Max(0, _redundancyConfig.ServiceLevelBase - 50);
|
|
||||||
var calculator = new ServiceLevelCalculator();
|
|
||||||
|
|
||||||
data.ServiceLevel = calculator.Calculate(baseLevel, mxConnected, dbConnected);
|
|
||||||
data.RedundancyRole = _redundancyConfig.Role;
|
|
||||||
data.RedundancyMode = _redundancyConfig.Mode;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Non-redundant: 255 when healthy, 0 when both down
|
|
||||||
data.ServiceLevel = mxConnected ? (byte)255 : (byte)0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates the JSON payload for the /api/health endpoint.
|
|
||||||
/// </summary>
|
|
||||||
public string GenerateHealthJson()
|
|
||||||
{
|
|
||||||
var data = GetHealthData();
|
|
||||||
return JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a focused health status HTML page for operators and monitoring dashboards.
|
|
||||||
/// </summary>
|
|
||||||
public string GenerateHealthHtml()
|
|
||||||
{
|
|
||||||
var data = GetHealthData();
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
|
|
||||||
var statusColor = data.Status == "Healthy" ? "#00cc66" : data.Status == "Degraded" ? "#cccc33" : "#cc3333";
|
|
||||||
var mxColor = data.Components.MxAccess == "Connected" ? "#00cc66" : "#cc3333";
|
|
||||||
var dbColor = data.Components.Database == "Connected" ? "#00cc66" : "#cc3333";
|
|
||||||
var uaColor = data.Components.OpcUaServer == "Running" ? "#00cc66" : "#cc3333";
|
|
||||||
|
|
||||||
sb.AppendLine("<!DOCTYPE html><html><head>");
|
|
||||||
sb.AppendLine("<meta charset='utf-8'>");
|
|
||||||
sb.AppendLine($"<meta http-equiv='refresh' content='{_refreshIntervalSeconds}'>");
|
|
||||||
sb.AppendLine("<title>LmxOpcUa Health</title>");
|
|
||||||
sb.AppendLine("<style>");
|
|
||||||
sb.AppendLine(
|
|
||||||
"body { font-family: monospace; background: #1a1a2e; color: #eee; padding: 20px; margin: 0; }");
|
|
||||||
sb.AppendLine(".header { text-align: center; padding: 30px 0; }");
|
|
||||||
sb.AppendLine(
|
|
||||||
".status-badge { display: inline-block; font-size: 2em; font-weight: bold; padding: 15px 40px; border-radius: 12px; letter-spacing: 2px; }");
|
|
||||||
sb.AppendLine(".service-level { text-align: center; font-size: 4em; font-weight: bold; margin: 20px 0; }");
|
|
||||||
sb.AppendLine(".service-level .label { font-size: 0.3em; color: #999; display: block; }");
|
|
||||||
sb.AppendLine(
|
|
||||||
".components { display: flex; justify-content: center; gap: 20px; flex-wrap: wrap; margin: 30px auto; max-width: 800px; }");
|
|
||||||
sb.AppendLine(
|
|
||||||
".component { border: 2px solid #444; border-radius: 8px; padding: 20px; min-width: 200px; text-align: center; }");
|
|
||||||
sb.AppendLine(".component .name { font-size: 0.9em; color: #999; margin-bottom: 8px; }");
|
|
||||||
sb.AppendLine(".component .value { font-size: 1.3em; font-weight: bold; }");
|
|
||||||
sb.AppendLine(".meta { text-align: center; margin-top: 30px; color: #666; font-size: 0.85em; }");
|
|
||||||
sb.AppendLine(".redundancy { text-align: center; margin: 10px 0; color: #999; }");
|
|
||||||
sb.AppendLine(".redundancy b { color: #66ccff; }");
|
|
||||||
sb.AppendLine("</style></head><body>");
|
|
||||||
|
|
||||||
// Status badge
|
|
||||||
sb.AppendLine("<div class='header'>");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<div class='status-badge' style='background: {statusColor}; color: #000;'>{data.Status.ToUpperInvariant()}</div>");
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
// Service Level
|
|
||||||
sb.AppendLine($"<div class='service-level' style='color: {statusColor};'>");
|
|
||||||
sb.AppendLine("<span class='label'>SERVICE LEVEL</span>");
|
|
||||||
sb.AppendLine($"{data.ServiceLevel}");
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
// Redundancy info
|
|
||||||
if (data.RedundancyEnabled)
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<div class='redundancy'>Role: <b>{data.RedundancyRole}</b> | Mode: <b>{data.RedundancyMode}</b></div>");
|
|
||||||
|
|
||||||
var historianColor = data.Components.Historian == "Loaded" ? "#00cc66"
|
|
||||||
: data.Components.Historian == "Disabled" ? "#666" : "#cc3333";
|
|
||||||
var alarmColor = data.Components.Alarms == "Enabled" ? "#00cc66" : "#666";
|
|
||||||
|
|
||||||
// Component health cards
|
|
||||||
sb.AppendLine("<div class='components'>");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<div class='component' style='border-color: {mxColor};'><div class='name'>MXAccess</div><div class='value' style='color: {mxColor};'>{data.Components.MxAccess}</div></div>");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<div class='component' style='border-color: {dbColor};'><div class='name'>Galaxy Database</div><div class='value' style='color: {dbColor};'>{data.Components.Database}</div></div>");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<div class='component' style='border-color: {uaColor};'><div class='name'>OPC UA Server</div><div class='value' style='color: {uaColor};'>{data.Components.OpcUaServer}</div></div>");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<div class='component' style='border-color: {historianColor};'><div class='name'>Historian</div><div class='value' style='color: {historianColor};'>{data.Components.Historian}</div></div>");
|
|
||||||
sb.AppendLine(
|
|
||||||
$"<div class='component' style='border-color: {alarmColor};'><div class='name'>Alarm Tracking</div><div class='value' style='color: {alarmColor};'>{data.Components.Alarms}</div></div>");
|
|
||||||
sb.AppendLine("</div>");
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
sb.AppendLine($"<div class='meta'>Uptime: {data.Uptime} | {data.Timestamp:O}</div>");
|
|
||||||
|
|
||||||
sb.AppendLine("</body></html>");
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatUptime(TimeSpan ts)
|
|
||||||
{
|
|
||||||
if (ts.TotalDays >= 1)
|
|
||||||
return $"{(int)ts.TotalDays}d {ts.Hours}h {ts.Minutes}m";
|
|
||||||
if (ts.TotalHours >= 1)
|
|
||||||
return $"{(int)ts.TotalHours}h {ts.Minutes}m";
|
|
||||||
return $"{(int)ts.TotalMinutes}m {ts.Seconds}s";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Net;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Status
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// HTTP server for status dashboard. Routes: / → HTML, /api/status → JSON, /api/health → 200/503. (DASH-001)
|
|
||||||
/// </summary>
|
|
||||||
public class StatusWebServer : IDisposable
|
|
||||||
{
|
|
||||||
private static readonly ILogger Log = Serilog.Log.ForContext<StatusWebServer>();
|
|
||||||
private readonly int _port;
|
|
||||||
|
|
||||||
private readonly StatusReportService _reportService;
|
|
||||||
private CancellationTokenSource? _cts;
|
|
||||||
private HttpListener? _listener;
|
|
||||||
private Task? _listenTask;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new dashboard web server bound to the supplied report service and HTTP port.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="reportService">The report service used to generate dashboard responses.</param>
|
|
||||||
/// <param name="port">The HTTP port to listen on.</param>
|
|
||||||
public StatusWebServer(StatusReportService reportService, int port)
|
|
||||||
{
|
|
||||||
_reportService = reportService;
|
|
||||||
_port = port;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets a value indicating whether the dashboard listener is currently accepting requests.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsRunning => _listener?.IsListening ?? false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops the dashboard listener and releases its resources.
|
|
||||||
/// </summary>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Starts the HTTP listener and background request loop for the status dashboard.
|
|
||||||
/// </summary>
|
|
||||||
public bool Start()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_listener = new HttpListener();
|
|
||||||
_listener.Prefixes.Add($"http://localhost:{_port}/");
|
|
||||||
_listener.Start();
|
|
||||||
|
|
||||||
_cts = new CancellationTokenSource();
|
|
||||||
_listenTask = Task.Run(() => ListenLoopAsync(_cts.Token));
|
|
||||||
|
|
||||||
Log.Information("Status dashboard started on http://localhost:{Port}/", _port);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Error(ex, "Failed to start status dashboard on port {Port}", _port);
|
|
||||||
_listener = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops the dashboard listener and releases its HTTP resources.
|
|
||||||
/// </summary>
|
|
||||||
public void Stop()
|
|
||||||
{
|
|
||||||
_cts?.Cancel();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_listener?.Stop();
|
|
||||||
_listener?.Close();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
|
|
||||||
_listener = null;
|
|
||||||
try { _listenTask?.Wait(TimeSpan.FromSeconds(5)); } catch { /* timeout or faulted */ }
|
|
||||||
_listenTask = null;
|
|
||||||
Log.Information("Status dashboard stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ListenLoopAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
while (!ct.IsCancellationRequested && _listener != null && _listener.IsListening)
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var context = await _listener.GetContextAsync();
|
|
||||||
_ = HandleRequestAsync(context);
|
|
||||||
}
|
|
||||||
catch (ObjectDisposedException)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
catch (HttpListenerException)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Dashboard listener error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleRequestAsync(HttpListenerContext context)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var request = context.Request;
|
|
||||||
var response = context.Response;
|
|
||||||
|
|
||||||
// Only allow GET
|
|
||||||
if (request.HttpMethod != "GET")
|
|
||||||
{
|
|
||||||
response.StatusCode = 405;
|
|
||||||
response.Close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No-cache headers
|
|
||||||
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
|
|
||||||
response.Headers.Add("Pragma", "no-cache");
|
|
||||||
response.Headers.Add("Expires", "0");
|
|
||||||
|
|
||||||
var path = request.Url?.AbsolutePath ?? "/";
|
|
||||||
|
|
||||||
switch (path)
|
|
||||||
{
|
|
||||||
case "/":
|
|
||||||
await WriteResponse(response, _reportService.GenerateHtml(), "text/html", 200);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "/health":
|
|
||||||
await WriteResponse(response, _reportService.GenerateHealthHtml(), "text/html", 200);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "/api/status":
|
|
||||||
await WriteResponse(response, _reportService.GenerateJson(), "application/json", 200);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "/api/health":
|
|
||||||
var healthData = _reportService.GetHealthData();
|
|
||||||
var healthJson = _reportService.GenerateHealthJson();
|
|
||||||
var healthStatusCode = healthData.Status == "Unhealthy" ? 503 : 200;
|
|
||||||
await WriteResponse(response, healthJson, "application/json", healthStatusCode);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
response.StatusCode = 404;
|
|
||||||
response.Close();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Error handling dashboard request");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
context.Response.Close();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task WriteResponse(HttpListenerResponse response, string body, string contentType,
|
|
||||||
int statusCode)
|
|
||||||
{
|
|
||||||
var buffer = Encoding.UTF8.GetBytes(body);
|
|
||||||
response.StatusCode = statusCode;
|
|
||||||
response.ContentType = contentType;
|
|
||||||
response.ContentLength64 = buffer.Length;
|
|
||||||
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
|
|
||||||
response.Close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user