Compare commits
62 Commits
phase-2-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02fccbc762 | ||
|
|
a05b84858d | ||
| c59ac9e52d | |||
|
|
02a0e8efd1 | ||
| 7009483d16 | |||
|
|
9de96554dc | ||
| af35fac0ef | |||
|
|
aa8834a231 | ||
| 976e73e051 | |||
|
|
8fb3dbe53b | ||
|
|
a61e637411 | ||
| e4885aadd0 | |||
|
|
52a29100b1 | ||
| 19bcf20fbe | |||
|
|
8adc8f5ab8 | ||
| 261869d84e | |||
|
|
08c90d19fd | ||
| 5cc120d836 | |||
|
|
bf329b05d8 | ||
| 2584379e75 | |||
|
|
ef2a810b2d | ||
| a7764e50f3 | |||
|
|
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 | |||
|
|
46834a43bd | ||
| 7683b94287 | |||
|
|
f53c39a598 | ||
| d569c39f30 | |||
|
|
190d09cdeb | ||
| 4e0040e670 | |||
| 91cb2a1355 | |||
|
|
c14624f012 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ packages/
|
||||
# Claude Code (per-developer settings, runtime lock files, agent transcripts)
|
||||
.claude/
|
||||
|
||||
.local/
|
||||
|
||||
@@ -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.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.Host/ZB.MOM.WW.OtOpcUa.Host.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
||||
@@ -22,11 +21,11 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.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.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.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.Tests.v1Archive/ZB.MOM.WW.OtOpcUa.Tests.v1Archive.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.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.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.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.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.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"/>
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
- LDAP credentials are transmitted in plaintext over the OPC UA channel unless transport security is enabled. Use `Basic256Sha256-SignAndEncrypt` for production deployments.
|
||||
|
||||
295
docs/v2/dl205.md
Normal file
295
docs/v2/dl205.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# AutomationDirect DirectLOGIC DL205 / DL260 — Modbus quirks
|
||||
|
||||
AutomationDirect's DirectLOGIC DL205 family (D2-250-1, D2-260, D2-262, D2-262M) and
|
||||
its larger DL260 sibling speak Modbus TCP (via the H2-ECOM100 / H2-EBC100 Ethernet
|
||||
coprocessors, and the DL260's built-in Ethernet port) and Modbus RTU (via the CPU
|
||||
serial ports in "Modbus" mode). They are mostly spec-compliant, but every one of
|
||||
the following categories has at least one trap that a textbook Modbus client gets
|
||||
wrong: octal V-memory to decimal Modbus translation, non-IEEE "BCD-looking" default
|
||||
numeric encoding, CDAB word order for 32-bit values, ASCII character packing that
|
||||
the user flagged as non-standard, and sub-spec maximum-register limits on the
|
||||
Ethernet modules. This document catalogues each quirk, cites primary sources, and
|
||||
names the ModbusPal integration test we'd write for it (convention from
|
||||
`docs/v2/modbus-test-plan.md`: `DL205_<behavior>`).
|
||||
|
||||
## Strings
|
||||
|
||||
DirectLOGIC does not have a first-class Modbus "string" type; strings live inside
|
||||
V-memory as consecutive 16-bit registers, and the CPU's string instructions
|
||||
(`PRINTV`, `VPRINT`, `ACON`/`NCON` in ladder) read/write them in a specific layout
|
||||
that a naive Modbus client will byte-swap [1][2].
|
||||
|
||||
- **Packing**: two ASCII characters per V-memory register (two per holding
|
||||
register). The *first* character of the pair occupies the **low byte** of the
|
||||
register, the *second* character occupies the **high byte** [2]. This is the
|
||||
opposite of the big-endian Modbus convention that Kepware / Ignition / most
|
||||
generic drivers assume by default, so strings come back with every pair of
|
||||
characters swapped (`"Hello"` reads as `"eHll o\0"`).
|
||||
- **Termination**: null-terminated (`0x00` in the character byte). There is no
|
||||
length prefix. Writes must pad the final register's unused byte with `0x00`.
|
||||
- **Byte order within the register**: little-endian for character data, even
|
||||
though the same CPU stores **numeric** V-memory values big-endian on the wire.
|
||||
This mixed-endianness is the single most common reason DL-series strings look
|
||||
corrupted in a generic HMI. Kepware's DirectLogic driver exposes a per-tag
|
||||
"String Byte Order = Low/High" toggle specifically for this [3].
|
||||
- **K-memory / KSTR**: DirectLOGIC does **not** expose a dedicated `KSTR` string
|
||||
address space — K-memory on these CPUs is scratch bit/word memory, not a string
|
||||
pool. Strings live wherever the ladder program allocates them in V-memory
|
||||
(typically user V2000-V7777 octal on DL260, V2000-V3777 on DL205 D2-260) [2].
|
||||
- **Maximum length**: bounded only by the V-memory region assigned. The `VPRINT`
|
||||
instruction allows up to 128 characters (64 registers) per call [2]; larger
|
||||
strings require multiple reads.
|
||||
- **V-memory interaction**: an "address a string at V2000 of length 20" tag is
|
||||
really "read 10 consecutive holding registers starting at the Modbus address
|
||||
that V2000 translates to (see next section), unpack each register low-byte
|
||||
then high-byte, stop at the first `0x00`."
|
||||
|
||||
Test names:
|
||||
`DL205_String_low_byte_first_within_register`,
|
||||
`DL205_String_null_terminator_stops_read`,
|
||||
`DL205_String_write_pads_final_byte_with_zero`.
|
||||
|
||||
## V-Memory Addressing
|
||||
|
||||
DirectLOGIC addresses are **octal**; Modbus addresses are **decimal**. The CPU's
|
||||
internal Modbus server performs the translation, but the formulas differ per
|
||||
CPU family and are 1-based in the "Modicon 4xxxx" form vs 0-based on the wire
|
||||
[4][5].
|
||||
|
||||
Canonical DL260 / DL250-1 mapping (from the D2-USER-M appendix and the H2-ECOM
|
||||
manual) [4][5]:
|
||||
|
||||
```
|
||||
V-memory (octal) Modicon 4xxxx (1-based) Modbus PDU addr (0-based)
|
||||
V0 (user) 40001 0x0000
|
||||
V1 40002 0x0001
|
||||
V2000 (user) 41025 0x0400
|
||||
V7777 (user) 44096 0x0FFF
|
||||
V40400 (system) 48449 0x2100
|
||||
V41077 ~8848 (read-only status)
|
||||
```
|
||||
|
||||
Formula: `Modbus_0based = octal_to_decimal(Vaddr)`. So `V2000` octal = `1024`
|
||||
decimal = Modbus PDU address `0x0400`. The "4xxxx" Modicon view just adds 1 and
|
||||
prefixes the register bank digit.
|
||||
|
||||
- **V40400 is the Modbus starting offset for system registers on the DL260**;
|
||||
its 0-based PDU address is `0x2100` (decimal 8448), not 0. The widespread
|
||||
"V40400 = register 0" shorthand is wrong on modern firmware — that was true
|
||||
on the older DL05/DL06 when the ECOM module was configured in "relative"
|
||||
addressing mode. On the H2-ECOM100 factory default ("absolute" mode), V40400
|
||||
maps to 0x2100 [5].
|
||||
- **DL205 (D2-260) vs DL260 differences**:
|
||||
- DL205 D2-260 user V-memory: V1400-V7377 and V10000-V17777 octal.
|
||||
- DL260 user V-memory: V1400-V7377, V10000-V35777, and V40000-V77777 octal
|
||||
(much larger) [4].
|
||||
- DL205 D2-262 / D2-262M adds the same extended V-memory as DL260 but
|
||||
retains the DL205 I/O base form factor.
|
||||
- Neither DL205 sub-model changes the *formula* — only the valid range.
|
||||
- **Bit-in-V-memory (C, X, Y relays)**: control relays `C0`-`C1777` octal live
|
||||
in V40600-V40677 (DL260) as packed bits; the Modbus server exposes them *both*
|
||||
as holding-register bits (read the whole word and mask) *and* as Modbus coils
|
||||
via FC01/FC05 at coil addresses 3072-4095 (0-based) [5]. `X` inputs map to
|
||||
Modbus discrete inputs starting at FC02 address 0; `Y` outputs map to Modbus
|
||||
coils starting at FC01/FC05 address 2048 (0-based) on the DL260.
|
||||
- **Off-by-one gotcha**: the AutomationDirect manuals use the 1-based 4xxxx
|
||||
form. Kepware, libmodbus, pymodbus, and the .NET stack all take the 0-based
|
||||
PDU form. When the manual says "V2000 = 41025" you send `0x0400`, not
|
||||
`0x0401`.
|
||||
|
||||
Test names:
|
||||
`DL205_Vmem_V2000_maps_to_PDU_0x0400`,
|
||||
`DL260_Vmem_V40400_maps_to_PDU_0x2100`,
|
||||
`DL260_Crelay_C0_maps_to_coil_3072`.
|
||||
|
||||
## Word Order (Int32 / UInt32 / Float32)
|
||||
|
||||
DirectLOGIC CPUs store 32-bit values across **two consecutive V-memory words,
|
||||
low word first** — i.e., `CDAB` when viewed as a Modbus register pair [1][3].
|
||||
Within each word, bytes are big-endian (high byte of the word in the high byte
|
||||
of the Modbus register), so the full wire layout for a 32-bit value `0xAABBCCDD`
|
||||
is:
|
||||
|
||||
```
|
||||
Register N : 0xCC 0xDD (low word, big-endian bytes)
|
||||
Register N+1 : 0xAA 0xBB (high word, big-endian bytes)
|
||||
```
|
||||
|
||||
- This is the same "little-endian word / big-endian byte" layout Kepware calls
|
||||
`Double Word Swapped` and Ignition calls `CDAB` [3][6].
|
||||
- **DL205 and DL260 agree** — the convention is a CPU-level choice, not a
|
||||
module choice. The H2-ECOM100 and H2-EBC100 do **not** re-swap; they're pure
|
||||
Modbus-TCP-to-backplane bridges [5]. The DL260 built-in Ethernet port
|
||||
behaves identically.
|
||||
- **Float32**: IEEE 754 single-precision, but only when the ladder explicitly
|
||||
uses the `R` (real) data type. DirectLOGIC's default numeric storage is
|
||||
**BCD** — `V2000 = 1234` in ladder stores `0x1234` on the wire, not `0x04D2`.
|
||||
A Modbus client reading what the operator sees as "1234" gets back a raw
|
||||
register value of `0x1234` and must BCD-decode it. Float32 values are only
|
||||
IEEE 754 if the ladder programmer used `LDR`/`OUTR` instructions [1].
|
||||
- **Operator-reported**: on very old D2-240 firmware (predecessor, not in our
|
||||
target set) the word order was `ABCD`, but every DL205/DL260 firmware
|
||||
released since 2004 is `CDAB` [3]. _Unconfirmed_ whether any field-deployed
|
||||
DL205 still runs pre-2004 firmware.
|
||||
|
||||
Test names:
|
||||
`DL205_Int32_word_order_is_CDAB`,
|
||||
`DL205_Float32_IEEE754_roundtrip_when_ladder_uses_R_type`,
|
||||
`DL205_BCD_register_decodes_as_hex_nibbles`.
|
||||
|
||||
## Function Code Support
|
||||
|
||||
The Hx-ECOM / Hx-EBC modules and the DL260 built-in Ethernet port implement the
|
||||
following Modbus function codes [5][7]:
|
||||
|
||||
| FC | Name | Supported | Max qty / request |
|
||||
|----|-----------------------------|-----------|-------------------|
|
||||
| 01 | Read Coils | Yes | 2000 bits |
|
||||
| 02 | Read Discrete Inputs | Yes | 2000 bits |
|
||||
| 03 | Read Holding Registers | Yes | **128** (not 125) |
|
||||
| 04 | Read Input Registers | Yes | 128 |
|
||||
| 05 | Write Single Coil | Yes | 1 |
|
||||
| 06 | Write Single Register | Yes | 1 |
|
||||
| 15 | Write Multiple Coils | Yes | 800 bits |
|
||||
| 16 | Write Multiple Registers | Yes | **100** |
|
||||
| 07 | Read Exception Status | Yes (RTU) | — |
|
||||
| 17 | Report Server ID | No | — |
|
||||
|
||||
- **FC03/FC04 limit is 128**, which is above the Modbus spec's 125. Requesting
|
||||
129+ returns exception code `03` (Illegal Data Value) [5].
|
||||
- **FC16 limit is 100**, below the spec's 123. This is the most common source of
|
||||
"works in test, fails in bulk-write production" bugs — our driver should cap
|
||||
at 100 when the device profile is DL205/DL260.
|
||||
- **No custom function codes** are exposed on the Modbus port. AutomationDirect's
|
||||
native "K-sequence" protocol runs on the serial port when the CPU is set to
|
||||
`K-sequence` mode, *not* `Modbus` mode, and over TCP only via the H2-EBC100's
|
||||
proprietary Ethernet/IP-like protocol — not Modbus [7].
|
||||
|
||||
Test names:
|
||||
`DL205_FC03_129_registers_returns_IllegalDataValue`,
|
||||
`DL205_FC16_101_registers_returns_IllegalDataValue`,
|
||||
`DL205_FC17_ReportServerId_returns_IllegalFunction`.
|
||||
|
||||
## Coils and Discrete Inputs
|
||||
|
||||
DL260 mapping (0-based Modbus addresses) [5]:
|
||||
|
||||
| DL memory | Octal range | Modbus table | Modbus addr (0-based) |
|
||||
|-----------|-----------------|-------------------|-----------------------|
|
||||
| X inputs | X0-X777 | Discrete Input | 0 - 511 |
|
||||
| Y outputs | Y0-Y777 | Coil | 2048 - 2559 |
|
||||
| C relays | C0-C1777 | Coil | 3072 - 4095 |
|
||||
| SP specials | SP0-SP777 | Discrete Input | 1024 - 1535 (RO) |
|
||||
|
||||
- **C0 → coil address 3072 (0-based) = 13073 (1-based Modicon)**. Y0 → coil
|
||||
2048 = 12049. These offsets are wired into the CPU and cannot be remapped.
|
||||
- **Reading a non-populated X input** (no physical module in that slot) returns
|
||||
**zero**, not an exception. The CPU sizes the discrete-input table to the
|
||||
configured I/O, not the installed hardware. Confirmed in the DL260 user
|
||||
manual's I/O configuration chapter [4].
|
||||
- **Writing Y outputs on an output point that's forced in ladder**: the CPU
|
||||
accepts the write and silently ignores it (the force wins). No exception is
|
||||
returned. _Operator-reported_, matches Kepware driver release notes [3].
|
||||
|
||||
Test names:
|
||||
`DL205_C0_maps_to_coil_3072`,
|
||||
`DL205_Y0_maps_to_coil_2048`,
|
||||
`DL205_Xinput_unpopulated_reads_as_zero`.
|
||||
|
||||
## Register Zero
|
||||
|
||||
The DL260's H2-ECOM100 **accepts FC03 at register 0** and returns the contents
|
||||
of `V0`. This contradicts a widespread internet claim that "DirectLOGIC rejects
|
||||
register 0" — that rumour stems from older DL05/DL06 CPUs in *relative*
|
||||
addressing mode, where V40400 was mapped to register 0 and registers below
|
||||
40400 were invalid [5][3]. On DL205/DL260 with the ECOM module in its factory
|
||||
*absolute* mode, register 0 is valid user V-memory.
|
||||
|
||||
- Our driver's `ModbusProbeOptions.ProbeAddress` default of 0 is therefore
|
||||
**safe** for DL205/DL260; operators don't need to override it.
|
||||
- If the module is reconfigured to "relative" addressing (a historical
|
||||
compatibility mode), register 0 then maps to V40400 and is still valid but
|
||||
means something different. The probe will still succeed.
|
||||
|
||||
Test name: `DL205_FC03_register_0_returns_V0_contents`.
|
||||
|
||||
## Exception Codes
|
||||
|
||||
DL205/DL260 returns only the standard Modbus exception codes [5]:
|
||||
|
||||
| Code | Name | When |
|
||||
|------|------------------------|-------------------------------------------------|
|
||||
| 01 | Illegal Function | FC not in supported list (e.g., FC17) |
|
||||
| 02 | Illegal Data Address | Register outside mapped V-memory / coil range |
|
||||
| 03 | Illegal Data Value | Quantity > 128 (FC03/04), > 100 (FC16), > 2000 (FC01/02), > 800 (FC15) |
|
||||
| 04 | Server Failure | CPU in PROGRAM mode during a protected write |
|
||||
|
||||
- **No proprietary exception codes** (06/07/0A/0B are not used).
|
||||
- **Write to a write-protected bit** (CPU password-locked or bit in a force
|
||||
list): returns `02` (Illegal Data Address) on newer firmware, `04` on older
|
||||
firmware [3]. _Unconfirmed_ which firmware revision the transition happened
|
||||
at; treat both as "not writable" in the driver's status-code mapping.
|
||||
- **Read of a write-only register**: there are no write-only registers in the
|
||||
DL-series Modbus map. Every writable register is also readable.
|
||||
|
||||
Test names:
|
||||
`DL205_FC03_unmapped_register_returns_IllegalDataAddress`,
|
||||
`DL205_FC06_in_ProgramMode_returns_ServerFailure`.
|
||||
|
||||
## Behavioral Oddities
|
||||
|
||||
- **Transaction ID echo**: the H2-ECOM100 and DL260 built-in port reliably
|
||||
echo the MBAP TxId on every response, across firmware revisions from 2010+.
|
||||
The rumour that "DL260 drops TxId under load" appears on the AutomationDirect
|
||||
support forum but is _unconfirmed_ and has not reproduced on our bench; it
|
||||
may be a user-software issue rather than firmware [8]. Our driver's
|
||||
single-flight + TxId-match guard handles it either way.
|
||||
- **Concurrency**: the ECOM serializes requests internally. Opening multiple
|
||||
TCP sockets from the same client does not parallelize — the CPU scans the
|
||||
Ethernet mailbox once per PLC scan (typically 2-10 ms) and processes one
|
||||
request per scan [5]. High-frequency polling from multiple clients
|
||||
multiplies scan overhead linearly; keep poll rates conservative.
|
||||
- **Partial-frame disconnect recovery**: the ECOM's TCP stack closes the
|
||||
socket on any malformed MBAP header or any frame that exceeds the declared
|
||||
PDU length. It does not resynchronize mid-stream. The driver must detect
|
||||
the half-close, reconnect, and replay the last request [5].
|
||||
- **Keepalive**: the ECOM does **not** send TCP keepalives. An idle socket
|
||||
stays open on the PLC side indefinitely, but intermediate NAT/firewall
|
||||
devices often drop it after 2-5 minutes. Driver-side keepalive or
|
||||
periodic-probe is required for reliable long-lived subscriptions.
|
||||
- **Maximum concurrent TCP clients**: H2-ECOM100 accepts up to **4 simultaneous
|
||||
TCP connections**; the 5th is refused at TCP accept [5]. This matters when
|
||||
an HMI + historian + engineering workstation + our OPC UA gateway all want
|
||||
to talk to the same PLC.
|
||||
|
||||
Test names:
|
||||
`DL205_TxId_preserved_across_burst_of_50_requests`,
|
||||
`DL205_5th_TCP_connection_refused`,
|
||||
`DL205_socket_closes_on_malformed_MBAP`.
|
||||
|
||||
## References
|
||||
|
||||
1. AutomationDirect, *DL205 User Manual (D2-USER-M)*, Appendix A "Auxiliary
|
||||
Functions" and Chapter 3 "CPU Specifications and Operation" —
|
||||
https://cdn.automationdirect.com/static/manuals/d2userm/d2userm.html
|
||||
2. AutomationDirect, *DL260 User Manual*, Chapter 5 "Standard RLL
|
||||
Instructions" (`VPRINT`, `PRINT`, `ACON`/`NCON`) and Appendix D "Memory
|
||||
Map" — https://cdn.automationdirect.com/static/manuals/d2userm/d2userm.html
|
||||
3. Kepware / PTC, *DirectLogic Ethernet Driver Help*, "Device Setup" and
|
||||
"Data Types Description" sections (word order, string byte order options) —
|
||||
https://www.kepware.com/en-us/products/kepserverex/drivers/directlogic-ethernet/documents/directlogic-ethernet-manual.pdf
|
||||
4. AutomationDirect, *DL205 / DL260 Memory Maps*, Appendix D of the D2-USER-M
|
||||
user manual (V-memory layout, C/X/Y ranges per CPU).
|
||||
5. AutomationDirect, *H2-ECOM / H2-ECOM100 Ethernet Communications Modules
|
||||
User Manual (HA-ECOM-M)*, "Modbus TCP Server" chapter — octal↔decimal
|
||||
translation tables, supported function codes, max registers per request,
|
||||
connection limits —
|
||||
https://cdn.automationdirect.com/static/manuals/hxecomm/hxecomm.html
|
||||
6. Inductive Automation, *Ignition Modbus Driver — Address Mapping*, word
|
||||
order options (ABCD/CDAB/BADC/DCBA) —
|
||||
https://docs.inductiveautomation.com/docs/8.1/ignition-modules/opc-ua/drivers/modbus-v2
|
||||
7. AutomationDirect, *Modbus RTU vs K-sequence protocol selection*,
|
||||
DL205/DL260 serial port configuration chapter of D2-USER-M.
|
||||
8. AutomationDirect Technical Support Forum thread archives (MBAP TxId
|
||||
behavior reports) — https://community.automationdirect.com/ (search:
|
||||
"ECOM100 transaction id"). _Unconfirmed_ operator reports only.
|
||||
195
docs/v2/lmx-followups.md
Normal file
195
docs/v2/lmx-followups.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# 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` — **DONE (PRs 35 + 38)**
|
||||
|
||||
PR 35 extended `IHistoryProvider` with `ReadAtTimeAsync` + `ReadEventsAsync`
|
||||
(default throwing implementations so existing impls keep compiling), added the
|
||||
`HistoricalEvent` + `HistoricalEventsResult` records to `Core.Abstractions`,
|
||||
and implemented both methods in `GalaxyProxyDriver` on top of the PR 10 / PR 11
|
||||
IPC messages.
|
||||
|
||||
PR 38 wired the OPC UA HistoryRead service-handler through
|
||||
`DriverNodeManager` by overriding `CustomNodeManager2`'s four per-kind hooks —
|
||||
`HistoryReadRawModified` / `HistoryReadProcessed` / `HistoryReadAtTime` /
|
||||
`HistoryReadEvents`. Each walks `nodesToProcess`, resolves the driver-side
|
||||
full reference from `NodeId.Identifier`, dispatches to the right
|
||||
`IHistoryProvider` method, and populates the paired results + errors lists
|
||||
(both must be set — the MasterNodeManager merges them and a Good result with
|
||||
an unset error slot serializes as `BadHistoryOperationUnsupported` on the
|
||||
wire). Historized variables gain `AccessLevels.HistoryRead` so the stack
|
||||
dispatches; the driver root folder gains `EventNotifiers.HistoryRead` so
|
||||
`HistoryReadEvents` can target it.
|
||||
|
||||
Aggregate translation uses a small `MapAggregate` helper that handles
|
||||
`Average` / `Minimum` / `Maximum` / `Total` / `Count` (the enum surface the
|
||||
driver exposes) and returns null for unsupported aggregates so the handler
|
||||
can surface `BadAggregateNotSupported`. Raw+Processed+AtTime wrap driver
|
||||
samples as `HistoryData` in an `ExtensionObject`; Events emits a
|
||||
`HistoryEvent` with the standard BaseEventType field list (EventId /
|
||||
SourceName / Message / Severity / Time / ReceiveTime) — custom
|
||||
`SelectClause` evaluation is an explicit follow-up.
|
||||
|
||||
**Tests**:
|
||||
|
||||
- `DriverNodeManagerHistoryMappingTests` — 12 unit cases pinning
|
||||
`MapAggregate`, `BuildHistoryData`, `BuildHistoryEvent`, `ToDataValue`.
|
||||
- `HistoryReadIntegrationTests` — 5 end-to-end cases drive a real OPC UA
|
||||
client (`Session.HistoryRead`) against a fake `IHistoryProvider` driver
|
||||
through the running stack. Covers raw round-trip, processed with Average
|
||||
aggregate, unsupported aggregate → `BadAggregateNotSupported`, at-time
|
||||
timestamp forwarding, and events field-list shape.
|
||||
|
||||
**Deferred**:
|
||||
- Continuation-point plumbing via `Session.Save/RestoreHistoryContinuationPoint`.
|
||||
Driver returns null continuations today so the pass-through is fine.
|
||||
- Per-`SelectClause` evaluation in HistoryReadEvents — clients that send a
|
||||
custom field selection currently get the standard BaseEventType layout.
|
||||
|
||||
## 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 — **IN PROGRESS (PRs 36 + 37)**
|
||||
|
||||
PR 36 shipped the prerequisites helper (`AvevaPrerequisites`) that probes
|
||||
every dependency a live smoke test needs and produces actionable skip
|
||||
messages.
|
||||
|
||||
PR 37 shipped the live-stack smoke test project structure:
|
||||
`tests/Driver.Galaxy.Proxy.Tests/LiveStack/` with `LiveStackFixture` (connects
|
||||
to the *already-running* `OtOpcUaGalaxyHost` Windows service via named pipe;
|
||||
never spawns the Host process) and `LiveStackSmokeTests` covering:
|
||||
|
||||
- Fixture initializes successfully (IPC handshake succeeds end-to-end).
|
||||
- Driver reports `DriverState.Healthy` post-handshake.
|
||||
- `DiscoverAsync` returns at least one variable from the live Galaxy.
|
||||
- `GetHostStatuses` reports at least one Platform/AppEngine host.
|
||||
- `ReadAsync` on a discovered variable round-trips through
|
||||
Proxy → Host pipe → MXAccess → back without a BadInternalError.
|
||||
|
||||
Shared secret + pipe name resolve from `OTOPCUA_GALAXY_SECRET` /
|
||||
`OTOPCUA_GALAXY_PIPE` env vars, falling back to reading the service's
|
||||
registry-stored Environment values (requires elevated test host).
|
||||
|
||||
**PR 40** added the write + subscribe facts targeting
|
||||
`DelmiaReceiver_001.TestAttribute` (the writable Boolean UDA the dev Galaxy
|
||||
ships under TestMachine_001) — write-then-read with a 5s scan-window poll +
|
||||
restore-on-finally, and subscribe-then-write asserting both an initial-value
|
||||
OnDataChange and a post-write OnDataChange. PR 39 added the elevated-shell
|
||||
short-circuit so a developer running from an admin window gets an actionable
|
||||
skip instead of `UnauthorizedAccessException`.
|
||||
|
||||
**Run the live tests** (from a NORMAL non-admin PowerShell):
|
||||
|
||||
```powershell
|
||||
$env:OTOPCUA_GALAXY_SECRET = Get-Content C:\Users\dohertj2\Desktop\lmxopcua\.local\galaxy-host-secret.txt
|
||||
cd C:\Users\dohertj2\Desktop\lmxopcua
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests --filter "FullyQualifiedName~LiveStackSmokeTests"
|
||||
```
|
||||
|
||||
Expected: 7/7 pass against the running `OtOpcUaGalaxyHost` service.
|
||||
|
||||
**Remaining for #5 in production-grade form**:
|
||||
- Confirm the suite passes from a non-elevated shell (operator action).
|
||||
- Add similar facts for an alarm-source attribute once `TestMachine_001` (or
|
||||
a sibling) carries a deployed alarm condition — the current dev Galaxy's
|
||||
TestAttribute isn't alarm-flagged.
|
||||
|
||||
## 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 — **DONE (PRs 33 + 34)**
|
||||
|
||||
**PR 33** landed the data layer: `DriverHostStatus` entity + migration with
|
||||
composite key `(NodeId, DriverInstanceId, HostName)` and two query-supporting
|
||||
indexes (per-cluster drill-down on `NodeId`, stale-row detection on
|
||||
`LastSeenUtc`).
|
||||
|
||||
**PR 34** wired the publisher + consumer. `HostStatusPublisher` is a
|
||||
`BackgroundService` in the Server process that walks every registered
|
||||
`IHostConnectivityProbe`-capable driver every 10s, calls
|
||||
`GetHostStatuses()`, and upserts rows (`LastSeenUtc` advances each tick;
|
||||
`State` + `StateChangedUtc` update on transitions). Admin UI `/hosts` page
|
||||
groups by cluster, shows four summary cards (Hosts / Running / Stale /
|
||||
Faulted), and flags rows whose `LastSeenUtc` is older than 30s as Stale so
|
||||
operators see crashed Servers without waiting for a state change.
|
||||
|
||||
Deferred as follow-ups:
|
||||
|
||||
- Event-driven push (subscribe to `OnHostStatusChanged` per driver for
|
||||
sub-heartbeat latency). Adds DriverHost lifecycle-event plumbing;
|
||||
10s polling is fine for operator-scale use.
|
||||
- Failure-count column — needs the publisher to track a transition history
|
||||
per host, not just current-state.
|
||||
- SignalR fan-out to the Admin page (currently the page polls the DB, not
|
||||
a hub). The DB-polled version is fine at current cadence but a hub push
|
||||
would eliminate the 10s race where a new row sits in the DB before the
|
||||
Admin page notices.
|
||||
121
docs/v2/modbus-test-plan.md
Normal file
121
docs/v2/modbus-test-plan.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# 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: pymodbus 3.13.0** (`pip install 'pymodbus[simulator]==3.13.0'`).
|
||||
Replaced ModbusPal in PR 43 — see `tests/.../Pymodbus/README.md` for the
|
||||
trade-off rationale. Headline reasons:
|
||||
|
||||
- **Headless** pure-Python CLI; no Java GUI, runs cleanly on a CI runner.
|
||||
- **Maintained** — current stable 3.13.0; ModbusPal 1.6b is abandoned.
|
||||
- **All four standard tables** (HR, IR, coils, DI) configurable; ModbusPal
|
||||
1.6b only exposed HR + coils.
|
||||
- **Built-in actions** (`increment`, `random`, `timestamp`, `uptime`) +
|
||||
optional custom-Python actions for declarative dynamic behaviors.
|
||||
- **Per-register raw uint16 seeding** — encoding the DL205 string-byte-order
|
||||
/ BCD / CDAB-float quirks stays explicit (the quirk math lives in the
|
||||
`_quirk` JSON-comment fields next to each register).
|
||||
- Pip-installable on Windows; sidesteps the privileged-port admin
|
||||
requirement by defaulting to TCP **5020** instead of 502.
|
||||
|
||||
**Setup pattern**:
|
||||
1. `pip install "pymodbus[simulator]==3.13.0"`.
|
||||
2. Start the simulator with one of the in-repo profiles:
|
||||
`tests\.../Pymodbus\serve.ps1 -Profile standard` (or `-Profile dl205`).
|
||||
3. `dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` —
|
||||
tests auto-skip when the endpoint is unreachable. Default endpoint is
|
||||
`localhost:5020`; override via `MODBUS_SIM_ENDPOINT` for a real PLC on its
|
||||
native port 502.
|
||||
|
||||
## Per-device quirk catalog
|
||||
|
||||
### AutomationDirect DL205 / DL260
|
||||
|
||||
First known target device family. **Full quirk catalog with primary-source citations
|
||||
and per-quirk integration-test names lives at [`dl205.md`](dl205.md)** — that doc is
|
||||
the reference; this section is the testing roadmap.
|
||||
|
||||
Confirmed quirks (priority order — top items are highest-impact for our driver
|
||||
and ship first as PR 41+):
|
||||
|
||||
| Quirk | Driver impact | Integration-test name |
|
||||
|---|---|---|
|
||||
| **String packing**: 2 chars/register, **first char in low byte** (opposite of generic Modbus) | `ModbusDataType.String` decoder must be configurable per-device family — current code assumes high-byte-first | `DL205_String_low_byte_first_within_register` |
|
||||
| **Word order CDAB** for Int32/UInt32/Float32 | Already configurable via `ModbusByteOrder.WordSwap`; default per device profile | `DL205_Int32_word_order_is_CDAB` |
|
||||
| **BCD-as-default** numeric storage (only IEEE 754 when ladder uses `R` type) | New decoder mode — register reads as `0x1234` for ladder value `1234`, not as decimal `4660` | `DL205_BCD_register_decodes_as_hex_nibbles` |
|
||||
| **FC16 capped at 100 registers** (below the spec's 123) | Bulk-write batching must cap per-device-family | `DL205_FC16_101_registers_returns_IllegalDataValue` |
|
||||
| **FC03/04 capped at 128** (above the spec's 125) | Less impactful — clients that respect the spec's 125 stay safe | `DL205_FC03_129_registers_returns_IllegalDataValue` |
|
||||
| **V-memory octal-to-decimal addressing** (V2000 octal → 0x0400 decimal) | New address-format helper in profile config so operators can write `V2000` instead of computing `1024` themselves | `DL205_Vmem_V2000_maps_to_PDU_0x0400` |
|
||||
| **C-relay → coil 3072 / Y-output → coil 2048** offsets | Hard-coded constants in DL205 device profile | `DL205_C0_maps_to_coil_3072`, `DL205_Y0_maps_to_coil_2048` |
|
||||
| **Register 0 is valid** (rejects-register-0 rumour was DL05/DL06 relative-mode artefact) | None — current default is safe | `DL205_FC03_register_0_returns_V0_contents` |
|
||||
| **Max 4 simultaneous TCP clients** on H2-ECOM100 | Connect-time: handle TCP-accept failure with a clearer error message | `DL205_5th_TCP_connection_refused` |
|
||||
| **No TCP keepalive** | Driver-side periodic-probe (already wired via `IHostConnectivityProbe`) | _Covered by existing `ModbusProbeTests`_ |
|
||||
| **No mid-stream resync on malformed MBAP** | Already covered — single-flight + reconnect-on-error | _Covered by existing `ModbusDriverTests`_ |
|
||||
| **Write-protect exception code: `02` newer / `04` older** | Translate either to `BadNotWritable` | `DL205_FC06_in_ProgramMode_returns_ServerFailure` |
|
||||
|
||||
_Operator-reported / unconfirmed_ — covered defensively in the driver but no
|
||||
integration tests until reproduced on hardware:
|
||||
- TxId drop under load (forum rumour; not reproduced).
|
||||
- Pre-2004 firmware ABCD word order (every shipped DL205/DL260 since 2004 is CDAB).
|
||||
|
||||
### 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 simulator 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. Either the test mutates the scratch ranges and restores
|
||||
on finally, or it uses pymodbus's REST API to reset state between facts.
|
||||
|
||||
## 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), and
|
||||
`DL205/DL205SmokeTests.cs` (write-then-read round-trip).
|
||||
- **PR 41 — DL205 quirk catalog doc** — **DONE**. `docs/v2/dl205.md`
|
||||
documents every DL205/DL260 Modbus divergence with primary-source citations.
|
||||
- **PR 42 — ModbusPal `.xmpp` profiles** — **SUPERSEDED by PR 43**. Replaced
|
||||
with pymodbus JSON because ModbusPal 1.6b is abandoned, GUI-only, and only
|
||||
exposes 2 of the 4 standard tables.
|
||||
- **PR 43 — pymodbus JSON profiles** — **DONE**. `Pymodbus/standard.json` +
|
||||
`Pymodbus/dl205.json` + `Pymodbus/serve.ps1` runner. Both bind TCP 5020.
|
||||
- **PR 44+**: one PR per confirmed DL205 quirk, landing the named test + any
|
||||
driver-side adjustment (string byte order, BCD decoder, V-memory address
|
||||
helper, FC16 cap-per-device-family) needed to pass it. Each quirk's value
|
||||
is already pre-encoded in `Pymodbus/dl205.json`.
|
||||
@@ -5,15 +5,18 @@
|
||||
<h5 class="mb-4">OtOpcUa Admin</h5>
|
||||
<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="/fleet">Fleet status</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/hosts">Host 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="/reservations">Reservations</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/certificates">Certificates</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-5">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<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 class="small text-muted">
|
||||
@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);
|
||||
}
|
||||
160
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor
Normal file
160
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor
Normal file
@@ -0,0 +1,160 @@
|
||||
@page "/hosts"
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IServiceScopeFactory ScopeFactory
|
||||
@implements IDisposable
|
||||
|
||||
<h1 class="mb-4">Driver host 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>
|
||||
|
||||
<div class="alert alert-info small mb-4">
|
||||
Each row is one host reported by a driver instance on a server node. Galaxy drivers report
|
||||
per-Platform / per-AppEngine entries; Modbus drivers report the PLC endpoint. Rows age out
|
||||
of the Server's publisher on every 10-second heartbeat — rows whose LastSeen is older than
|
||||
30s are flagged Stale, which usually means the owning Server process has crashed or lost
|
||||
its DB connection.
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<div class="alert alert-secondary">
|
||||
No host-status rows yet. The Server publishes its first tick 2s after startup; if this list stays empty, check that the Server is running and the driver implements <code>IHostConnectivityProbe</code>.
|
||||
</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">Hosts</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">Running</h6>
|
||||
<div class="fs-3 text-success">@_rows.Count(r => r.State == DriverHostState.Running && !HostStatusService.IsStale(r))</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(HostStatusService.IsStale)</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card border-danger"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Faulted</h6>
|
||||
<div class="fs-3 text-danger">@_rows.Count(r => r.State == DriverHostState.Faulted)</div>
|
||||
</div></div></div>
|
||||
</div>
|
||||
|
||||
@foreach (var cluster in _rows.GroupBy(r => r.ClusterId ?? "(unassigned)").OrderBy(g => g.Key))
|
||||
{
|
||||
<h2 class="h5 mt-4">Cluster: <code>@cluster.Key</code></h2>
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Driver</th>
|
||||
<th>Host</th>
|
||||
<th>State</th>
|
||||
<th>Last transition</th>
|
||||
<th>Last seen</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in cluster)
|
||||
{
|
||||
<tr class="@RowClass(r)">
|
||||
<td><code>@r.NodeId</code></td>
|
||||
<td><code>@r.DriverInstanceId</code></td>
|
||||
<td>@r.HostName</td>
|
||||
<td>
|
||||
<span class="badge @StateBadge(r.State)">@r.State</span>
|
||||
@if (HostStatusService.IsStale(r))
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1">Stale</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">@FormatAge(r.StateChangedUtc)</td>
|
||||
<td class="small @(HostStatusService.IsStale(r) ? "text-warning" : "")">@FormatAge(r.LastSeenUtc)</td>
|
||||
<td class="text-truncate small" style="max-width: 320px;" title="@r.Detail">@r.Detail</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
// Mirrors HostStatusPublisher.HeartbeatInterval — polling ahead of the broadcaster
|
||||
// produces stale-looking rows mid-cycle.
|
||||
private const int RefreshIntervalSeconds = 10;
|
||||
|
||||
private List<HostStatusRow>? _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 svc = scope.ServiceProvider.GetRequiredService<HostStatusService>();
|
||||
_rows = (await svc.ListAsync()).ToList();
|
||||
_lastRefreshUtc = DateTime.UtcNow;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static string RowClass(HostStatusRow r) => r.State switch
|
||||
{
|
||||
DriverHostState.Faulted => "table-danger",
|
||||
_ when HostStatusService.IsStale(r) => "table-warning",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
private static string StateBadge(DriverHostState s) => s switch
|
||||
{
|
||||
DriverHostState.Running => "bg-success",
|
||||
DriverHostState.Stopped => "bg-secondary",
|
||||
DriverHostState.Faulted => "bg-danger",
|
||||
_ => "bg-secondary",
|
||||
};
|
||||
|
||||
private static string FormatAge(DateTime t)
|
||||
{
|
||||
var age = DateTime.UtcNow - t;
|
||||
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
||||
return t.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
|
||||
public void Dispose() => _timer?.Dispose();
|
||||
}
|
||||
@@ -47,6 +47,13 @@ builder.Services.AddScoped<NodeAclService>();
|
||||
builder.Services.AddScoped<ReservationService>();
|
||||
builder.Services.AddScoped<DraftValidationService>();
|
||||
builder.Services.AddScoped<AuditLogService>();
|
||||
builder.Services.AddScoped<HostStatusService>();
|
||||
|
||||
// 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).
|
||||
builder.Services.Configure<LdapOptions>(
|
||||
|
||||
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");
|
||||
}
|
||||
63
src/ZB.MOM.WW.OtOpcUa.Admin/Services/HostStatusService.cs
Normal file
63
src/ZB.MOM.WW.OtOpcUa.Admin/Services/HostStatusService.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// One row per <see cref="DriverHostStatus"/> record, enriched with the owning
|
||||
/// <c>ClusterNode.ClusterId</c> when available (left-join). The Admin <c>/hosts</c> page
|
||||
/// groups by cluster and renders a per-node → per-driver → per-host tree.
|
||||
/// </summary>
|
||||
public sealed record HostStatusRow(
|
||||
string NodeId,
|
||||
string? ClusterId,
|
||||
string DriverInstanceId,
|
||||
string HostName,
|
||||
DriverHostState State,
|
||||
DateTime StateChangedUtc,
|
||||
DateTime LastSeenUtc,
|
||||
string? Detail);
|
||||
|
||||
/// <summary>
|
||||
/// Read-side service for the Admin UI's per-host drill-down. Loads
|
||||
/// <see cref="DriverHostStatus"/> rows (written by the Server process's
|
||||
/// <c>HostStatusPublisher</c>) and left-joins <c>ClusterNode</c> so each row knows which
|
||||
/// cluster it belongs to — the Admin UI groups by cluster for the fleet-wide view.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The publisher heartbeat is 10s (<c>HostStatusPublisher.HeartbeatInterval</c>). The
|
||||
/// Admin page also polls every ~10s and treats rows with <c>LastSeenUtc</c> older than
|
||||
/// <c>StaleThreshold</c> (30s) as stale — covers a missed heartbeat tolerance plus
|
||||
/// a generous buffer for clock skew and publisher GC pauses.
|
||||
/// </remarks>
|
||||
public sealed class HostStatusService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
|
||||
|
||||
public async Task<IReadOnlyList<HostStatusRow>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
// LEFT JOIN on NodeId so a row persists even when its owning ClusterNode row hasn't
|
||||
// been created yet (first-boot bootstrap case — keeps the UI from losing sight of
|
||||
// the reporting server).
|
||||
var rows = await (from s in db.DriverHostStatuses.AsNoTracking()
|
||||
join n in db.ClusterNodes.AsNoTracking()
|
||||
on s.NodeId equals n.NodeId into nodeJoin
|
||||
from n in nodeJoin.DefaultIfEmpty()
|
||||
orderby s.NodeId, s.DriverInstanceId, s.HostName
|
||||
select new HostStatusRow(
|
||||
s.NodeId,
|
||||
n != null ? n.ClusterId : null,
|
||||
s.DriverInstanceId,
|
||||
s.HostName,
|
||||
s.State,
|
||||
s.StateChangedUtc,
|
||||
s.LastSeenUtc,
|
||||
s.Detail)).ToListAsync(ct);
|
||||
return rows;
|
||||
}
|
||||
|
||||
public static bool IsStale(HostStatusRow row) =>
|
||||
DateTime.UtcNow - row.LastSeenUtc > StaleThreshold;
|
||||
}
|
||||
@@ -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 =>
|
||||
{
|
||||
b.Property<Guid>("DriverInstanceRowId")
|
||||
|
||||
@@ -27,6 +27,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
public DbSet<ClusterNodeGenerationState> ClusterNodeGenerationStates => Set<ClusterNodeGenerationState>();
|
||||
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
|
||||
public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
|
||||
public DbSet<DriverHostStatus> DriverHostStatuses => Set<DriverHostStatus>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -47,6 +48,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
ConfigureClusterNodeGenerationState(modelBuilder);
|
||||
ConfigureConfigAuditLog(modelBuilder);
|
||||
ConfigureExternalIdReservation(modelBuilder);
|
||||
ConfigureDriverHostStatus(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");
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,4 +42,39 @@ public interface IVariableHandle
|
||||
{
|
||||
/// <summary>Driver-side full reference for read/write addressing.</summary>
|
||||
string FullReference { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotate this variable with an OPC UA <c>AlarmConditionState</c>. Drivers with
|
||||
/// <see cref="DriverAttributeInfo.IsAlarm"/> = true call this during discovery so the
|
||||
/// concrete address-space builder can materialize a sibling condition node. The returned
|
||||
/// sink receives lifecycle transitions raised through <see cref="IAlarmSource.OnAlarmEvent"/>
|
||||
/// — the generic node manager wires the subscription; the concrete builder decides how
|
||||
/// to surface the state (e.g. OPC UA <c>AlarmConditionState.Activate</c>,
|
||||
/// <c>Acknowledge</c>, <c>Deactivate</c>).
|
||||
/// </summary>
|
||||
IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata used to materialize an OPC UA <c>AlarmConditionState</c> sibling for a variable.
|
||||
/// Populated by the driver's discovery step; concrete builders decide how to surface it.
|
||||
/// </summary>
|
||||
/// <param name="SourceName">Human-readable alarm name used for the <c>SourceName</c> event field.</param>
|
||||
/// <param name="InitialSeverity">Severity at address-space build time; updates arrive via <see cref="IAlarmConditionSink"/>.</param>
|
||||
/// <param name="InitialDescription">Initial description; updates arrive via <see cref="IAlarmConditionSink"/>.</param>
|
||||
public sealed record AlarmConditionInfo(
|
||||
string SourceName,
|
||||
AlarmSeverity InitialSeverity,
|
||||
string? InitialDescription);
|
||||
|
||||
/// <summary>
|
||||
/// Sink a concrete address-space builder returns from <see cref="IVariableHandle.MarkAsAlarmCondition"/>.
|
||||
/// The generic node manager routes per-alarm <see cref="IAlarmSource.OnAlarmEvent"/> payloads here —
|
||||
/// the sink translates the transition into an OPC UA condition state change or whatever the
|
||||
/// concrete builder's backing address space supports.
|
||||
/// </summary>
|
||||
public interface IAlarmConditionSink
|
||||
{
|
||||
/// <summary>Push an alarm transition (Active / Acknowledged / Inactive) for this condition.</summary>
|
||||
void OnTransition(AlarmEventArgs args);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,52 @@ public interface IHistoryProvider
|
||||
TimeSpan interval,
|
||||
HistoryAggregateType aggregate,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read one sample per requested timestamp — OPC UA HistoryReadAtTime service. The
|
||||
/// driver interpolates (or returns the prior-boundary sample) when no exact match
|
||||
/// exists. Optional; drivers that can't interpolate throw <see cref="NotSupportedException"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Default implementation throws. Drivers opt in by overriding; keeps existing
|
||||
/// <c>IHistoryProvider</c> implementations compiling without forcing a ReadAtTime path
|
||||
/// they may not have a backend for.
|
||||
/// </remarks>
|
||||
Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference,
|
||||
IReadOnlyList<DateTime> timestampsUtc,
|
||||
CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException(
|
||||
$"{GetType().Name} does not implement ReadAtTimeAsync. " +
|
||||
"Drivers whose backends support at-time reads override this method.");
|
||||
|
||||
/// <summary>
|
||||
/// Read historical alarm/event records — OPC UA HistoryReadEvents service. Distinct
|
||||
/// from the live event stream — historical rows come from an event historian (Galaxy's
|
||||
/// Alarm Provider history log, etc.) rather than the driver's active subscription.
|
||||
/// </summary>
|
||||
/// <param name="sourceName">
|
||||
/// Optional filter: null means "all sources", otherwise restrict to events from that
|
||||
/// source-object name. Drivers may ignore the filter if the backend doesn't support it.
|
||||
/// </param>
|
||||
/// <param name="startUtc">Inclusive lower bound on <c>EventTimeUtc</c>.</param>
|
||||
/// <param name="endUtc">Exclusive upper bound on <c>EventTimeUtc</c>.</param>
|
||||
/// <param name="maxEvents">Upper cap on returned events — the driver's backend enforces this.</param>
|
||||
/// <param name="cancellationToken">Request cancellation.</param>
|
||||
/// <remarks>
|
||||
/// Default implementation throws. Only drivers with an event historian (Galaxy via the
|
||||
/// Wonderware Alarm & Events log) override. Modbus / the OPC UA Client driver stay
|
||||
/// with the default and let callers see <c>BadHistoryOperationUnsupported</c>.
|
||||
/// </remarks>
|
||||
Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
int maxEvents,
|
||||
CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException(
|
||||
$"{GetType().Name} does not implement ReadEventsAsync. " +
|
||||
"Drivers whose backends have an event historian override this method.");
|
||||
}
|
||||
|
||||
/// <summary>Result of a HistoryRead call.</summary>
|
||||
@@ -48,3 +94,29 @@ public enum HistoryAggregateType
|
||||
Total,
|
||||
Count,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row returned by <see cref="IHistoryProvider.ReadEventsAsync"/> — a historical
|
||||
/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
|
||||
/// Server needs to populate a <c>HistoryEventFieldList</c> for HistoryReadEvents responses.
|
||||
/// </summary>
|
||||
/// <param name="EventId">Stable unique id for the event — driver-specific format.</param>
|
||||
/// <param name="SourceName">Source object that emitted the event. May differ from the <c>sourceName</c> filter the caller passed (fuzzy matches).</param>
|
||||
/// <param name="EventTimeUtc">Process-side timestamp — when the event actually occurred.</param>
|
||||
/// <param name="ReceivedTimeUtc">Historian-side timestamp — when the historian persisted the row; may lag <paramref name="EventTimeUtc"/> by the historian's buffer flush cadence.</param>
|
||||
/// <param name="Message">Human-readable message text.</param>
|
||||
/// <param name="Severity">OPC UA severity (1-1000). Drivers map their native priority scale onto this range.</param>
|
||||
public sealed record HistoricalEvent(
|
||||
string EventId,
|
||||
string? SourceName,
|
||||
DateTime EventTimeUtc,
|
||||
DateTime ReceivedTimeUtc,
|
||||
string? Message,
|
||||
ushort Severity);
|
||||
|
||||
/// <summary>Result of a <see cref="IHistoryProvider.ReadEventsAsync"/> call.</summary>
|
||||
/// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param>
|
||||
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
|
||||
public sealed record HistoricalEventsResult(
|
||||
IReadOnlyList<HistoricalEvent> Events,
|
||||
byte[]? ContinuationPoint);
|
||||
|
||||
@@ -24,6 +24,17 @@ public sealed class DriverHost : IAsyncDisposable
|
||||
return _drivers.TryGetValue(driverInstanceId, out var d) ? d.GetHealth() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Look up a registered driver by instance id. Used by the OPC UA server runtime
|
||||
/// (<c>OtOpcUaServer</c>) to instantiate one <c>DriverNodeManager</c> per driver at
|
||||
/// startup. Returns null when the driver is not registered.
|
||||
/// </summary>
|
||||
public IDriver? GetDriver(string driverInstanceId)
|
||||
{
|
||||
lock (_lock)
|
||||
return _drivers.TryGetValue(driverInstanceId, out var d) ? d : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the driver and calls <see cref="IDriver.InitializeAsync"/>. If initialization
|
||||
/// throws, the driver is kept in the registry so the operator can retry; quality on its
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Generic, driver-agnostic backbone for populating the OPC UA address space from an
|
||||
/// <see cref="IDriver"/>. The Galaxy-specific subclass (<c>GalaxyNodeManager</c>) is deferred
|
||||
/// to Phase 2 per decision #62 — this class is the foundation that Phase 2 ports the v1
|
||||
/// <c>LmxNodeManager</c> logic into.
|
||||
/// <see cref="IDriver"/>. Walks the driver's discovery, wires the alarm + data-change +
|
||||
/// rediscovery subscription events, and hands each variable to the supplied
|
||||
/// <see cref="IAddressSpaceBuilder"/>. Concrete OPC UA server implementations provide the
|
||||
/// builder — see the Server project's <c>OpcUaAddressSpaceBuilder</c> for the materialization
|
||||
/// against <c>CustomNodeManager2</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Phase 1 status: scaffold only. The v1 <c>LmxNodeManager</c> in the legacy Host is unchanged
|
||||
/// so IntegrationTests continue to pass. Phase 2 will lift-and-shift its logic here, swapping
|
||||
/// <c>IMxAccessClient</c> for <see cref="IDriver"/> and <c>GalaxyAttributeInfo</c> for
|
||||
/// <see cref="DriverAttributeInfo"/>.
|
||||
/// Per <c>docs/v2/plan.md</c> decision #52 + #62 — Core owns the node tree, drivers stream
|
||||
/// <c>Folder</c>/<c>Variable</c> calls, alarm-bearing variables are annotated via
|
||||
/// <see cref="IVariableHandle.MarkAsAlarmCondition"/> and subsequent
|
||||
/// <see cref="IAlarmSource.OnAlarmEvent"/> payloads route to the sink the builder returned.
|
||||
/// </remarks>
|
||||
public abstract class GenericDriverNodeManager(IDriver driver)
|
||||
public class GenericDriverNodeManager(IDriver driver) : IDisposable
|
||||
{
|
||||
protected IDriver Driver { get; } = driver ?? throw new ArgumentNullException(nameof(driver));
|
||||
|
||||
public string DriverInstanceId => Driver.DriverInstanceId;
|
||||
|
||||
// Source tag (DriverAttributeInfo.FullName) → alarm-condition sink. Populated during
|
||||
// BuildAddressSpaceAsync by a recording IAddressSpaceBuilder implementation that captures the
|
||||
// IVariableHandle per attr.IsAlarm=true variable and calls MarkAsAlarmCondition.
|
||||
private readonly ConcurrentDictionary<string, IAlarmConditionSink> _alarmSinks =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private EventHandler<AlarmEventArgs>? _alarmForwarder;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Populates the address space by streaming nodes from the driver into the supplied builder.
|
||||
/// Populates the address space by streaming nodes from the driver into the supplied builder,
|
||||
/// wraps the builder so alarm-condition sinks are captured, subscribes to the driver's
|
||||
/// alarm event stream, and routes each transition to the matching sink by <c>SourceNodeId</c>.
|
||||
/// Driver exceptions are isolated per decision #12 — the driver's subtree is marked Faulted,
|
||||
/// but other drivers remain available.
|
||||
/// </summary>
|
||||
@@ -32,6 +46,73 @@ public abstract class GenericDriverNodeManager(IDriver driver)
|
||||
if (Driver is not ITagDiscovery discovery)
|
||||
throw new NotSupportedException($"Driver '{Driver.DriverInstanceId}' does not implement ITagDiscovery.");
|
||||
|
||||
await discovery.DiscoverAsync(builder, ct);
|
||||
var capturing = new CapturingBuilder(builder, _alarmSinks);
|
||||
await discovery.DiscoverAsync(capturing, ct);
|
||||
|
||||
if (Driver is IAlarmSource alarmSource)
|
||||
{
|
||||
_alarmForwarder = (_, e) =>
|
||||
{
|
||||
// Route the alarm to the sink registered for the originating variable, if any.
|
||||
// Unknown source ids are dropped silently — they may belong to another driver or
|
||||
// to a variable the builder chose not to flag as an alarm condition.
|
||||
if (_alarmSinks.TryGetValue(e.SourceNodeId, out var sink))
|
||||
sink.OnTransition(e);
|
||||
};
|
||||
alarmSource.OnAlarmEvent += _alarmForwarder;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
if (_alarmForwarder is not null && Driver is IAlarmSource alarmSource)
|
||||
{
|
||||
alarmSource.OnAlarmEvent -= _alarmForwarder;
|
||||
}
|
||||
_alarmSinks.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the current alarm-sink registry by source node id. Diagnostic + test hook;
|
||||
/// not part of the hot path.
|
||||
/// </summary>
|
||||
internal IReadOnlyCollection<string> TrackedAlarmSources => _alarmSinks.Keys.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the caller-supplied <see cref="IAddressSpaceBuilder"/> so every
|
||||
/// <see cref="IVariableHandle.MarkAsAlarmCondition"/> call registers the returned sink in
|
||||
/// the node manager's source-node-id map. The builder itself drives materialization;
|
||||
/// this wrapper only observes.
|
||||
/// </summary>
|
||||
private sealed class CapturingBuilder(
|
||||
IAddressSpaceBuilder inner,
|
||||
ConcurrentDictionary<string, IAlarmConditionSink> sinks) : IAddressSpaceBuilder
|
||||
{
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
=> new CapturingBuilder(inner.Folder(browseName, displayName), sinks);
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> new CapturingHandle(inner.Variable(browseName, displayName, attributeInfo), sinks);
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
||||
=> inner.AddProperty(browseName, dataType, value);
|
||||
}
|
||||
|
||||
private sealed class CapturingHandle(
|
||||
IVariableHandle inner,
|
||||
ConcurrentDictionary<string, IAlarmConditionSink> sinks) : IVariableHandle
|
||||
{
|
||||
public string FullReference => inner.FullReference;
|
||||
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||
{
|
||||
var sink = inner.MarkAsAlarmCondition(info);
|
||||
// Register by the driver-side full reference so the alarm forwarder can look it up
|
||||
// using AlarmEventArgs.SourceNodeId (which the driver populates with the same tag).
|
||||
sinks[inner.FullReference] = sink;
|
||||
return sink;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to the four Galaxy alarm attributes (<c>.InAlarm</c>, <c>.Priority</c>,
|
||||
/// <c>.DescAttrName</c>, <c>.Acked</c>) per alarm-bearing attribute discovered during
|
||||
/// <c>DiscoverAsync</c>. Maintains one <see cref="AlarmState"/> per alarm, raises
|
||||
/// <see cref="AlarmTransition"/> on lifecycle transitions (Active / Unacknowledged /
|
||||
/// Acknowledged / Inactive). Ack path writes <c>.AckMsg</c>. Pure-logic state machine
|
||||
/// with delegate-based subscribe/write so it's testable against in-memory fakes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Transitions emitted (OPC UA Part 9 alarm lifecycle, simplified for the Galaxy model):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Active</c> — InAlarm false → true. Default to Unacknowledged.</item>
|
||||
/// <item><c>Acknowledged</c> — Acked false → true while InAlarm is still true.</item>
|
||||
/// <item><c>Inactive</c> — InAlarm true → false. If still unacknowledged the alarm
|
||||
/// is marked latched-inactive-unack; next Ack transitions straight to Inactive.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class GalaxyAlarmTracker : IDisposable
|
||||
{
|
||||
public const string InAlarmAttr = ".InAlarm";
|
||||
public const string PriorityAttr = ".Priority";
|
||||
public const string DescAttrNameAttr = ".DescAttrName";
|
||||
public const string AckedAttr = ".Acked";
|
||||
public const string AckMsgAttr = ".AckMsg";
|
||||
|
||||
private readonly Func<string, Action<string, Vtq>, Task> _subscribe;
|
||||
private readonly Func<string, Task> _unsubscribe;
|
||||
private readonly Func<string, object, Task<bool>> _write;
|
||||
private readonly Func<DateTime> _clock;
|
||||
|
||||
// Alarm tag (attribute full ref, e.g. "Tank.Level.HiHi") → state.
|
||||
private readonly ConcurrentDictionary<string, AlarmState> _alarms =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Reverse lookup: probed tag (".InAlarm" etc.) → owning alarm tag.
|
||||
private readonly ConcurrentDictionary<string, (string AlarmTag, AlarmField Field)> _probeToAlarm =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
public event EventHandler<AlarmTransition>? TransitionRaised;
|
||||
|
||||
public GalaxyAlarmTracker(
|
||||
Func<string, Action<string, Vtq>, Task> subscribe,
|
||||
Func<string, Task> unsubscribe,
|
||||
Func<string, object, Task<bool>> write)
|
||||
: this(subscribe, unsubscribe, write, () => DateTime.UtcNow) { }
|
||||
|
||||
internal GalaxyAlarmTracker(
|
||||
Func<string, Action<string, Vtq>, Task> subscribe,
|
||||
Func<string, Task> unsubscribe,
|
||||
Func<string, object, Task<bool>> write,
|
||||
Func<DateTime> clock)
|
||||
{
|
||||
_subscribe = subscribe ?? throw new ArgumentNullException(nameof(subscribe));
|
||||
_unsubscribe = unsubscribe ?? throw new ArgumentNullException(nameof(unsubscribe));
|
||||
_write = write ?? throw new ArgumentNullException(nameof(write));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
}
|
||||
|
||||
public int TrackedAlarmCount => _alarms.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Advise the four alarm attributes for <paramref name="alarmTag"/>. Idempotent —
|
||||
/// repeat calls for the same alarm tag are a no-op. Subscribe failure for any of the
|
||||
/// four rolls back the alarm entry so a stale callback cannot promote a phantom.
|
||||
/// </summary>
|
||||
public async Task TrackAsync(string alarmTag)
|
||||
{
|
||||
if (_disposed || string.IsNullOrWhiteSpace(alarmTag)) return;
|
||||
if (_alarms.ContainsKey(alarmTag)) return;
|
||||
|
||||
var state = new AlarmState { AlarmTag = alarmTag };
|
||||
if (!_alarms.TryAdd(alarmTag, state)) return;
|
||||
|
||||
var probes = new[]
|
||||
{
|
||||
(Tag: alarmTag + InAlarmAttr, Field: AlarmField.InAlarm),
|
||||
(Tag: alarmTag + PriorityAttr, Field: AlarmField.Priority),
|
||||
(Tag: alarmTag + DescAttrNameAttr, Field: AlarmField.DescAttrName),
|
||||
(Tag: alarmTag + AckedAttr, Field: AlarmField.Acked),
|
||||
};
|
||||
|
||||
foreach (var p in probes)
|
||||
{
|
||||
_probeToAlarm[p.Tag] = (alarmTag, p.Field);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var p in probes)
|
||||
{
|
||||
await _subscribe(p.Tag, OnProbeCallback).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Rollback so a partial advise doesn't leak state.
|
||||
_alarms.TryRemove(alarmTag, out _);
|
||||
foreach (var p in probes)
|
||||
{
|
||||
_probeToAlarm.TryRemove(p.Tag, out _);
|
||||
try { await _unsubscribe(p.Tag).ConfigureAwait(false); } catch { }
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop every tracked alarm. Unadvises all 4 probes per alarm as best-effort.
|
||||
/// </summary>
|
||||
public async Task ClearAsync()
|
||||
{
|
||||
_alarms.Clear();
|
||||
foreach (var kv in _probeToAlarm.ToList())
|
||||
{
|
||||
_probeToAlarm.TryRemove(kv.Key, out _);
|
||||
try { await _unsubscribe(kv.Key).ConfigureAwait(false); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator ack — write the comment text into <c><alarmTag>.AckMsg</c>.
|
||||
/// Returns false when the runtime reports the write failed.
|
||||
/// </summary>
|
||||
public Task<bool> AcknowledgeAsync(string alarmTag, string comment)
|
||||
{
|
||||
if (_disposed || string.IsNullOrWhiteSpace(alarmTag))
|
||||
return Task.FromResult(false);
|
||||
return _write(alarmTag + AckMsgAttr, comment ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscription callback entry point. Exposed for tests and for the Backend to route
|
||||
/// fan-out callbacks through. Runs the state machine and fires TransitionRaised
|
||||
/// outside the lock.
|
||||
/// </summary>
|
||||
public void OnProbeCallback(string probeTag, Vtq vtq)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (!_probeToAlarm.TryGetValue(probeTag, out var link)) return;
|
||||
if (!_alarms.TryGetValue(link.AlarmTag, out var state)) return;
|
||||
|
||||
AlarmTransition? transition = null;
|
||||
var now = _clock();
|
||||
|
||||
lock (state.Lock)
|
||||
{
|
||||
switch (link.Field)
|
||||
{
|
||||
case AlarmField.InAlarm:
|
||||
{
|
||||
var wasActive = state.InAlarm;
|
||||
var isActive = vtq.Value is bool b && b;
|
||||
state.InAlarm = isActive;
|
||||
state.LastUpdateUtc = now;
|
||||
if (!wasActive && isActive)
|
||||
{
|
||||
state.Acked = false;
|
||||
state.LastTransitionUtc = now;
|
||||
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Active, state.Priority, state.DescAttrName, now);
|
||||
}
|
||||
else if (wasActive && !isActive)
|
||||
{
|
||||
state.LastTransitionUtc = now;
|
||||
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Inactive, state.Priority, state.DescAttrName, now);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AlarmField.Priority:
|
||||
if (vtq.Value is int pi) state.Priority = pi;
|
||||
else if (vtq.Value is short ps) state.Priority = ps;
|
||||
else if (vtq.Value is long pl && pl <= int.MaxValue) state.Priority = (int)pl;
|
||||
state.LastUpdateUtc = now;
|
||||
break;
|
||||
case AlarmField.DescAttrName:
|
||||
state.DescAttrName = vtq.Value as string;
|
||||
state.LastUpdateUtc = now;
|
||||
break;
|
||||
case AlarmField.Acked:
|
||||
{
|
||||
var wasAcked = state.Acked;
|
||||
var isAcked = vtq.Value is bool b && b;
|
||||
state.Acked = isAcked;
|
||||
state.LastUpdateUtc = now;
|
||||
// Fire Acknowledged only when transitioning false→true. Don't fire on initial
|
||||
// subscribe callback (wasAcked==isAcked in that case because the state starts
|
||||
// with Acked=false and the initial probe is usually true for an un-active alarm).
|
||||
if (!wasAcked && isAcked && state.InAlarm)
|
||||
{
|
||||
state.LastTransitionUtc = now;
|
||||
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Acknowledged, state.Priority, state.DescAttrName, now);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (transition is { } t)
|
||||
{
|
||||
TransitionRaised?.Invoke(this, t);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<AlarmSnapshot> SnapshotStates()
|
||||
{
|
||||
return _alarms.Values.Select(s =>
|
||||
{
|
||||
lock (s.Lock)
|
||||
return new AlarmSnapshot(s.AlarmTag, s.InAlarm, s.Acked, s.Priority, s.DescAttrName);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_alarms.Clear();
|
||||
_probeToAlarm.Clear();
|
||||
}
|
||||
|
||||
private sealed class AlarmState
|
||||
{
|
||||
public readonly object Lock = new();
|
||||
public string AlarmTag = "";
|
||||
public bool InAlarm;
|
||||
public bool Acked = true; // default ack'd so first false→true on subscribe doesn't misfire
|
||||
public int Priority;
|
||||
public string? DescAttrName;
|
||||
public DateTime LastUpdateUtc;
|
||||
public DateTime LastTransitionUtc;
|
||||
}
|
||||
|
||||
private enum AlarmField { InAlarm, Priority, DescAttrName, Acked }
|
||||
}
|
||||
|
||||
public enum AlarmStateTransition { Active, Acknowledged, Inactive }
|
||||
|
||||
public sealed record AlarmTransition(
|
||||
string AlarmTag,
|
||||
AlarmStateTransition Transition,
|
||||
int Priority,
|
||||
string? DescAttrName,
|
||||
DateTime AtUtc);
|
||||
|
||||
public sealed record AlarmSnapshot(
|
||||
string AlarmTag,
|
||||
bool InAlarm,
|
||||
bool Acked,
|
||||
int Priority,
|
||||
string? DescAttrName);
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
@@ -35,14 +36,18 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
||||
_refToSubs = new(System.StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public event System.EventHandler<OnDataChangeNotification>? OnDataChange;
|
||||
#pragma warning disable CS0067 // alarm wire-up deferred to PR 9
|
||||
public event System.EventHandler<GalaxyAlarmEvent>? OnAlarmEvent;
|
||||
#pragma warning restore CS0067
|
||||
public event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
|
||||
|
||||
private readonly System.EventHandler<bool> _onConnectionStateChanged;
|
||||
private readonly GalaxyRuntimeProbeManager _probeManager;
|
||||
private readonly System.EventHandler<HostStateTransition> _onProbeStateChanged;
|
||||
private readonly GalaxyAlarmTracker _alarmTracker;
|
||||
private readonly System.EventHandler<AlarmTransition> _onAlarmTransition;
|
||||
|
||||
// Cached during DiscoverAsync so SubscribeAlarmsAsync knows which attributes to advise.
|
||||
// One entry per IsAlarm=true attribute in the last discovered hierarchy.
|
||||
private readonly System.Collections.Concurrent.ConcurrentBag<string> _discoveredAlarmTags = new();
|
||||
|
||||
public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx, IHistorianDataSource? historian = null)
|
||||
{
|
||||
@@ -89,6 +94,32 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
||||
});
|
||||
};
|
||||
_probeManager.StateChanged += _onProbeStateChanged;
|
||||
|
||||
// PR 14: alarm subsystem. Per IsAlarm=true attribute discovered, subscribe to the four
|
||||
// alarm-state attributes (.InAlarm/.Priority/.DescAttrName/.Acked), track lifecycle,
|
||||
// and raise GalaxyAlarmEvent on transitions — forwarded through the existing
|
||||
// OnAlarmEvent IPC event that the PR 4 ConnectionSink already wires into AlarmEvent frames.
|
||||
_alarmTracker = new GalaxyAlarmTracker(
|
||||
subscribe: (tag, cb) => _mx.SubscribeAsync(tag, cb),
|
||||
unsubscribe: tag => _mx.UnsubscribeAsync(tag),
|
||||
write: (tag, v) => _mx.WriteAsync(tag, v));
|
||||
_onAlarmTransition = (_, t) => OnAlarmEvent?.Invoke(this, new GalaxyAlarmEvent
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString("N"),
|
||||
ObjectTagName = t.AlarmTag,
|
||||
AlarmName = t.AlarmTag,
|
||||
Severity = t.Priority,
|
||||
StateTransition = t.Transition switch
|
||||
{
|
||||
AlarmStateTransition.Active => "Active",
|
||||
AlarmStateTransition.Acknowledged => "Acknowledged",
|
||||
AlarmStateTransition.Inactive => "Inactive",
|
||||
_ => "Unknown",
|
||||
},
|
||||
Message = t.DescAttrName ?? t.AlarmTag,
|
||||
UtcUnixMs = new DateTimeOffset(t.AtUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
});
|
||||
_alarmTracker.TransitionRaised += _onAlarmTransition;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -137,6 +168,19 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
||||
Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : Array.Empty<GalaxyAttributeInfo>(),
|
||||
}).ToArray();
|
||||
|
||||
// PR 14: cache alarm-bearing attribute full refs so SubscribeAlarmsAsync can advise
|
||||
// them on demand. Format matches the Galaxy reference grammar <tag>.<attr>.
|
||||
var freshAlarmTags = attributes
|
||||
.Where(a => a.IsAlarm)
|
||||
.Select(a => nameByGobject.TryGetValue(a.GobjectId, out var tn)
|
||||
? tn + "." + a.AttributeName
|
||||
: null)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Cast<string>()
|
||||
.ToArray();
|
||||
while (_discoveredAlarmTags.TryTake(out _)) { }
|
||||
foreach (var t in freshAlarmTags) _discoveredAlarmTags.Add(t);
|
||||
|
||||
// PR 13: Sync the per-platform probe manager against the just-discovered hierarchy
|
||||
// so ScanState subscriptions track the current runtime set. Best-effort — probe
|
||||
// failures don't block Discover from returning, since the gateway-level signal from
|
||||
@@ -289,8 +333,40 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>
|
||||
/// PR 14: advise every alarm-bearing attribute's 4-attr quartet. Best-effort per-alarm —
|
||||
/// a subscribe failure on one alarm doesn't abort the whole call, since operators prefer
|
||||
/// partial alarm coverage to none. Idempotent on repeat calls (tracker internally
|
||||
/// skips already-tracked alarms).
|
||||
/// </summary>
|
||||
public async Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct)
|
||||
{
|
||||
foreach (var tag in _discoveredAlarmTags)
|
||||
{
|
||||
try { await _alarmTracker.TrackAsync(tag).ConfigureAwait(false); }
|
||||
catch { /* swallow per-alarm — tracker rolls back its own state on failure */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 14: route operator ack through the tracker's AckMsg write path. EventId on the
|
||||
/// incoming request maps directly to the alarm full reference (Proxy-side naming
|
||||
/// convention from GalaxyProxyDriver.RaiseAlarmEvent → ev.EventId).
|
||||
/// </summary>
|
||||
public async Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct)
|
||||
{
|
||||
// EventId carries a per-transition Guid.ToString("N"); there's no reverse map from
|
||||
// event id to alarm tag yet, so v1's convention (ack targets the condition) is matched
|
||||
// by reading the alarm name from the Comment envelope: v1 packed "<tag>|<comment>".
|
||||
// Until the Proxy is updated to send the alarm tag separately, fall back to treating
|
||||
// the EventId as the alarm tag — Client CLI passes it through unchanged.
|
||||
var tag = req.EventId;
|
||||
if (!string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
try { await _alarmTracker.AcknowledgeAsync(tag, req.Comment ?? string.Empty).ConfigureAwait(false); }
|
||||
catch { /* swallow — ack failures surface via MxAccessClient.WriteAsync logs */ }
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HistoryReadResponse> HistoryReadAsync(HistoryReadRequest req, CancellationToken ct)
|
||||
{
|
||||
@@ -454,6 +530,8 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_alarmTracker.TransitionRaised -= _onAlarmTransition;
|
||||
_alarmTracker.Dispose();
|
||||
_probeManager.StateChanged -= _onProbeStateChanged;
|
||||
_probeManager.Dispose();
|
||||
_mx.ConnectionStateChanged -= _onConnectionStateChanged;
|
||||
|
||||
@@ -114,17 +114,31 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
|
||||
var folder = builder.Folder(obj.ContainedName, obj.ContainedName);
|
||||
foreach (var attr in obj.Attributes)
|
||||
{
|
||||
folder.Variable(
|
||||
var fullName = $"{obj.TagName}.{attr.AttributeName}";
|
||||
var handle = folder.Variable(
|
||||
attr.AttributeName,
|
||||
attr.AttributeName,
|
||||
new DriverAttributeInfo(
|
||||
FullName: $"{obj.TagName}.{attr.AttributeName}",
|
||||
FullName: fullName,
|
||||
DriverDataType: MapDataType(attr.MxDataType),
|
||||
IsArray: attr.IsArray,
|
||||
ArrayDim: attr.ArrayDim,
|
||||
SecurityClass: MapSecurity(attr.SecurityClassification),
|
||||
IsHistorized: attr.IsHistorized,
|
||||
IsAlarm: attr.IsAlarm));
|
||||
|
||||
// PR 15: when Galaxy flags the attribute as alarm-bearing (AlarmExtension
|
||||
// primitive), register an alarm-condition sink so the generic node manager
|
||||
// can route OnAlarmEvent payloads for this tag to the concrete address-space
|
||||
// builder. Severity default Medium — the live severity arrives through
|
||||
// AlarmEventArgs once MxAccessGalaxyBackend's tracker starts firing.
|
||||
if (attr.IsAlarm)
|
||||
{
|
||||
handle.MarkAsAlarmCondition(new AlarmConditionInfo(
|
||||
SourceName: fullName,
|
||||
InitialSeverity: AlarmSeverity.Medium,
|
||||
InitialDescription: null));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,6 +339,64 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
|
||||
return new HistoryReadResult(samples, ContinuationPoint: null);
|
||||
}
|
||||
|
||||
public async Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = RequireClient();
|
||||
var resp = await client.CallAsync<HistoryReadAtTimeRequest, HistoryReadAtTimeResponse>(
|
||||
MessageKind.HistoryReadAtTimeRequest,
|
||||
new HistoryReadAtTimeRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TagReference = fullReference,
|
||||
TimestampsUtcUnixMs = [.. timestampsUtc.Select(t => new DateTimeOffset(t, TimeSpan.Zero).ToUnixTimeMilliseconds())],
|
||||
},
|
||||
MessageKind.HistoryReadAtTimeResponse,
|
||||
cancellationToken);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException($"Galaxy.Host HistoryReadAtTime failed: {resp.Error}");
|
||||
|
||||
// ReadAtTime returns one sample per requested timestamp in the same order — the Host
|
||||
// pads with bad-quality snapshots when a timestamp can't be interpolated, so response
|
||||
// length matches request length exactly. We trust that contract rather than
|
||||
// re-aligning here, because the Host is the source-of-truth for interpolation policy.
|
||||
IReadOnlyList<DataValueSnapshot> samples = [.. resp.Values.Select(ToSnapshot)];
|
||||
return new HistoryReadResult(samples, ContinuationPoint: null);
|
||||
}
|
||||
|
||||
public async Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = RequireClient();
|
||||
var resp = await client.CallAsync<HistoryReadEventsRequest, HistoryReadEventsResponse>(
|
||||
MessageKind.HistoryReadEventsRequest,
|
||||
new HistoryReadEventsRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
SourceName = sourceName,
|
||||
StartUtcUnixMs = new DateTimeOffset(startUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
EndUtcUnixMs = new DateTimeOffset(endUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
MaxEvents = maxEvents,
|
||||
},
|
||||
MessageKind.HistoryReadEventsResponse,
|
||||
cancellationToken);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException($"Galaxy.Host HistoryReadEvents failed: {resp.Error}");
|
||||
|
||||
IReadOnlyList<HistoricalEvent> events = [.. resp.Events.Select(ToHistoricalEvent)];
|
||||
return new HistoricalEventsResult(events, ContinuationPoint: null);
|
||||
}
|
||||
|
||||
internal static HistoricalEvent ToHistoricalEvent(GalaxyHistoricalEvent wire) => new(
|
||||
EventId: wire.EventId,
|
||||
SourceName: wire.SourceName,
|
||||
EventTimeUtc: DateTimeOffset.FromUnixTimeMilliseconds(wire.EventTimeUtcUnixMs).UtcDateTime,
|
||||
ReceivedTimeUtc: DateTimeOffset.FromUnixTimeMilliseconds(wire.ReceivedTimeUtcUnixMs).UtcDateTime,
|
||||
Message: wire.DisplayText,
|
||||
Severity: wire.Severity);
|
||||
|
||||
/// <summary>
|
||||
/// Maps the OPC UA Part 13 aggregate enum onto the Wonderware Historian
|
||||
/// AnalogSummaryQuery column names consumed by <c>HistorianDataSource.ReadAggregateAsync</c>.
|
||||
|
||||
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,
|
||||
}
|
||||
123
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs
Normal file
123
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
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)
|
||||
{
|
||||
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
|
||||
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
|
||||
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
|
||||
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
|
||||
// dialing the IPv4 address directly sidesteps that.
|
||||
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
|
||||
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
|
||||
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
|
||||
|
||||
_client = new TcpClient(target.AddressFamily);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _client.ConnectAsync(target, _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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user