Compare commits
77 Commits
phase-2-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cde018aec1 | ||
|
|
9892a0253d | ||
|
|
b5464f11ee | ||
| dae29f14c8 | |||
| f306793e36 | |||
| 9e61873cc0 | |||
| 1a60470d4a | |||
| 635f67bb02 | |||
|
|
a3f2f95344 | ||
|
|
463c5a4320 | ||
|
|
2b5222f5db | ||
|
|
8248b126ce | ||
|
|
cd19022d19 | ||
| 5ee9acb255 | |||
|
|
02fccbc762 | ||
| faeab34541 | |||
|
|
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>.
|
||||
|
||||
165
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs
Normal file
165
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// AutomationDirect DirectLOGIC address-translation helpers. DL205 / DL260 / DL350 CPUs
|
||||
/// address V-memory in OCTAL while the Modbus wire uses DECIMAL PDU addresses — operators
|
||||
/// see "V2000" in the PLC ladder-logic editor but the Modbus client must write PDU 0x0400.
|
||||
/// The formulas differ between user V-memory (simple octal-to-decimal) and system V-memory
|
||||
/// (fixed bank mappings), so the two cases are separate methods rather than one overloaded
|
||||
/// "ToPdu" call.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See <c>docs/v2/dl205.md</c> §V-memory for the full CPU-family matrix + rationale.
|
||||
/// References: D2-USER-M appendix (DL205/D2-260), H2-ECOM-M §6.5 (absolute vs relative
|
||||
/// addressing), AutomationDirect forum guidance on V40400 system-base.
|
||||
/// </remarks>
|
||||
public static class DirectLogicAddress
|
||||
{
|
||||
/// <summary>
|
||||
/// Convert a DirectLOGIC user V-memory address (octal) to a 0-based Modbus PDU address.
|
||||
/// Accepts bare octal (<c>"2000"</c>) or <c>V</c>-prefixed (<c>"V2000"</c>). Range
|
||||
/// depends on CPU model — DL205 D2-260 user memory is V1400-V7377 + V10000-V17777
|
||||
/// octal, DL260 extends to V77777 octal.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">Input is null / empty / contains non-octal digits (8,9).</exception>
|
||||
/// <exception cref="OverflowException">Parsed value exceeds ushort.MaxValue (0xFFFF).</exception>
|
||||
public static ushort UserVMemoryToPdu(string vAddress)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vAddress))
|
||||
throw new ArgumentException("V-memory address must not be empty", nameof(vAddress));
|
||||
var s = vAddress.Trim();
|
||||
if (s[0] == 'V' || s[0] == 'v') s = s.Substring(1);
|
||||
if (s.Length == 0)
|
||||
throw new ArgumentException($"V-memory address '{vAddress}' has no digits", nameof(vAddress));
|
||||
|
||||
// Octal conversion. Reject 8/9 digits up-front — int.Parse in the obvious base would
|
||||
// accept them silently because .NET has no built-in base-8 parser.
|
||||
uint result = 0;
|
||||
foreach (var ch in s)
|
||||
{
|
||||
if (ch < '0' || ch > '7')
|
||||
throw new ArgumentException(
|
||||
$"V-memory address '{vAddress}' contains non-octal digit '{ch}' — DirectLOGIC V-addresses are octal (0-7)",
|
||||
nameof(vAddress));
|
||||
result = result * 8 + (uint)(ch - '0');
|
||||
if (result > ushort.MaxValue)
|
||||
throw new OverflowException(
|
||||
$"V-memory address '{vAddress}' exceeds the 16-bit Modbus PDU address range");
|
||||
}
|
||||
return (ushort)result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DirectLOGIC system V-memory starts at octal V40400 on DL260 / H2-ECOM100 in factory
|
||||
/// "absolute" addressing mode. Unlike user V-memory, the mapping is NOT a simple
|
||||
/// octal-to-decimal conversion — the CPU relocates the system bank to Modbus PDU 0x2100
|
||||
/// (decimal 8448). This helper returns the CPU-family base plus a user-supplied offset
|
||||
/// within the system bank.
|
||||
/// </summary>
|
||||
public const ushort SystemVMemoryBasePdu = 0x2100;
|
||||
|
||||
/// <param name="offsetWithinSystemBank">
|
||||
/// 0-based register offset within the system bank. Pass 0 for V40400 itself; pass 1 for
|
||||
/// V40401 (octal), and so on. NOT an octal-decoded value — the system bank lives at
|
||||
/// consecutive PDU addresses, so the offset is plain decimal.
|
||||
/// </param>
|
||||
public static ushort SystemVMemoryToPdu(ushort offsetWithinSystemBank)
|
||||
{
|
||||
var pdu = SystemVMemoryBasePdu + offsetWithinSystemBank;
|
||||
if (pdu > ushort.MaxValue)
|
||||
throw new OverflowException(
|
||||
$"System V-memory offset {offsetWithinSystemBank} maps past 0xFFFF");
|
||||
return (ushort)pdu;
|
||||
}
|
||||
|
||||
// Bit-memory bases per DL260 user manual §I/O-configuration.
|
||||
// Numbers after X / Y / C / SP are OCTAL in DirectLOGIC notation. The Modbus base is
|
||||
// added to the octal-decoded offset; e.g. Y017 = Modbus coil 2048 + octal(17) = 2048 + 15 = 2063.
|
||||
|
||||
/// <summary>
|
||||
/// DL260 Y-output coil base. Y0 octal → Modbus coil address 2048 (0-based).
|
||||
/// </summary>
|
||||
public const ushort YOutputBaseCoil = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// DL260 C-relay coil base. C0 octal → Modbus coil address 3072 (0-based).
|
||||
/// </summary>
|
||||
public const ushort CRelayBaseCoil = 3072;
|
||||
|
||||
/// <summary>
|
||||
/// DL260 X-input discrete-input base. X0 octal → Modbus discrete input 0.
|
||||
/// </summary>
|
||||
public const ushort XInputBaseDiscrete = 0;
|
||||
|
||||
/// <summary>
|
||||
/// DL260 SP special-relay discrete-input base. SP0 octal → Modbus discrete input 1024.
|
||||
/// Read-only; writing SP relays is rejected with Illegal Data Address.
|
||||
/// </summary>
|
||||
public const ushort SpecialBaseDiscrete = 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Translate a DirectLOGIC Y-output address (e.g. <c>"Y0"</c>, <c>"Y17"</c>) to its
|
||||
/// 0-based Modbus coil address on DL260. The trailing number is OCTAL, matching the
|
||||
/// ladder-logic editor's notation.
|
||||
/// </summary>
|
||||
public static ushort YOutputToCoil(string yAddress) =>
|
||||
AddOctalOffset(YOutputBaseCoil, StripPrefix(yAddress, 'Y'));
|
||||
|
||||
/// <summary>
|
||||
/// Translate a DirectLOGIC C-relay address (e.g. <c>"C0"</c>, <c>"C1777"</c>) to its
|
||||
/// 0-based Modbus coil address.
|
||||
/// </summary>
|
||||
public static ushort CRelayToCoil(string cAddress) =>
|
||||
AddOctalOffset(CRelayBaseCoil, StripPrefix(cAddress, 'C'));
|
||||
|
||||
/// <summary>
|
||||
/// Translate a DirectLOGIC X-input address (e.g. <c>"X0"</c>, <c>"X17"</c>) to its
|
||||
/// 0-based Modbus discrete-input address. Reading an unpopulated X returns 0, not an
|
||||
/// exception — the CPU sizes the table to configured I/O, not installed modules.
|
||||
/// </summary>
|
||||
public static ushort XInputToDiscrete(string xAddress) =>
|
||||
AddOctalOffset(XInputBaseDiscrete, StripPrefix(xAddress, 'X'));
|
||||
|
||||
/// <summary>
|
||||
/// Translate a DirectLOGIC SP-special-relay address (e.g. <c>"SP0"</c>) to its 0-based
|
||||
/// Modbus discrete-input address. Accepts <c>"SP"</c> prefix case-insensitively.
|
||||
/// </summary>
|
||||
public static ushort SpecialToDiscrete(string spAddress)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(spAddress))
|
||||
throw new ArgumentException("SP address must not be empty", nameof(spAddress));
|
||||
var s = spAddress.Trim();
|
||||
if (s.Length >= 2 && (s[0] == 'S' || s[0] == 's') && (s[1] == 'P' || s[1] == 'p'))
|
||||
s = s.Substring(2);
|
||||
return AddOctalOffset(SpecialBaseDiscrete, s);
|
||||
}
|
||||
|
||||
private static string StripPrefix(string address, char expectedPrefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
throw new ArgumentException("Address must not be empty", nameof(address));
|
||||
var s = address.Trim();
|
||||
if (s.Length > 0 && char.ToUpperInvariant(s[0]) == char.ToUpperInvariant(expectedPrefix))
|
||||
s = s.Substring(1);
|
||||
return s;
|
||||
}
|
||||
|
||||
private static ushort AddOctalOffset(ushort baseAddr, string octalDigits)
|
||||
{
|
||||
if (octalDigits.Length == 0)
|
||||
throw new ArgumentException("Address has no digits", nameof(octalDigits));
|
||||
uint offset = 0;
|
||||
foreach (var ch in octalDigits)
|
||||
{
|
||||
if (ch < '0' || ch > '7')
|
||||
throw new ArgumentException(
|
||||
$"Address contains non-octal digit '{ch}' — DirectLOGIC I/O addresses are octal (0-7)",
|
||||
nameof(octalDigits));
|
||||
offset = offset * 8 + (uint)(ch - '0');
|
||||
}
|
||||
var result = baseAddr + offset;
|
||||
if (result > ushort.MaxValue)
|
||||
throw new OverflowException($"Address {baseAddr}+{offset} exceeds 0xFFFF");
|
||||
return (ushort)result;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
732
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
Normal file
732
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
Normal file
@@ -0,0 +1,732 @@
|
||||
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 (ModbusException mex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, MapModbusExceptionToStatus(mex.ExceptionCode), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, mex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-Modbus-layer failure: socket dropped, timeout, malformed response. Surface
|
||||
// as communication error so callers can distinguish it from tag-level faults.
|
||||
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, 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;
|
||||
// Auto-chunk when the tag's register span exceeds the caller-configured cap.
|
||||
// Affects long strings (FC03/04 > 125 regs is spec-forbidden; DL205 caps at 128,
|
||||
// Mitsubishi Q caps at 64). Non-string tags max out at 4 regs so the cap never
|
||||
// triggers for numerics.
|
||||
var cap = _options.MaxRegistersPerRead == 0 ? (ushort)125 : _options.MaxRegistersPerRead;
|
||||
var data = quantity <= cap
|
||||
? await ReadRegisterBlockAsync(transport, fc, tag.Address, quantity, ct).ConfigureAwait(false)
|
||||
: await ReadRegisterBlockChunkedAsync(transport, fc, tag.Address, quantity, cap, ct).ConfigureAwait(false);
|
||||
return DecodeRegister(data, tag);
|
||||
}
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown region {tag.Region}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<byte[]> ReadRegisterBlockAsync(
|
||||
IModbusTransport transport, byte fc, ushort address, ushort quantity, CancellationToken ct)
|
||||
{
|
||||
var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(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 byte[resp[1]];
|
||||
Buffer.BlockCopy(resp, 2, data, 0, resp[1]);
|
||||
return data;
|
||||
}
|
||||
|
||||
private async Task<byte[]> ReadRegisterBlockChunkedAsync(
|
||||
IModbusTransport transport, byte fc, ushort address, ushort totalRegs, ushort cap, CancellationToken ct)
|
||||
{
|
||||
var assembled = new byte[totalRegs * 2];
|
||||
ushort done = 0;
|
||||
while (done < totalRegs)
|
||||
{
|
||||
var chunk = (ushort)Math.Min(cap, totalRegs - done);
|
||||
var chunkBytes = await ReadRegisterBlockAsync(transport, fc, (ushort)(address + done), chunk, ct).ConfigureAwait(false);
|
||||
Buffer.BlockCopy(chunkBytes, 0, assembled, done * 2, chunkBytes.Length);
|
||||
done += chunk;
|
||||
}
|
||||
return assembled;
|
||||
}
|
||||
|
||||
// ---- 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 (ModbusException mex)
|
||||
{
|
||||
results[i] = new WriteResult(MapModbusExceptionToStatus(mex.ExceptionCode));
|
||||
}
|
||||
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 writeCap = _options.MaxRegistersPerWrite == 0 ? (ushort)123 : _options.MaxRegistersPerWrite;
|
||||
if (qty > writeCap)
|
||||
throw new InvalidOperationException(
|
||||
$"Write of {qty} registers to {tag.Name} exceeds MaxRegistersPerWrite={writeCap}. " +
|
||||
$"Split the tag (e.g. shorter StringLength) — partial FC16 chunks would lose atomicity.");
|
||||
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 or ModbusDataType.Bcd16 => 1,
|
||||
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 or ModbusDataType.Bcd32 => 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.Bcd16:
|
||||
{
|
||||
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
|
||||
return (int)DecodeBcd(raw, nibbles: 4);
|
||||
}
|
||||
case ModbusDataType.Bcd32:
|
||||
{
|
||||
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||
var raw = BinaryPrimitives.ReadUInt32BigEndian(b);
|
||||
return (int)DecodeBcd(raw, nibbles: 8);
|
||||
}
|
||||
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. HighByteFirst (standard) packs the first char in
|
||||
// the high byte of each register; LowByteFirst (DL205/DL260) packs the first char
|
||||
// in the low byte. Respect StringLength (truncate nul-padded regions).
|
||||
var chars = new char[tag.StringLength];
|
||||
for (var i = 0; i < tag.StringLength; i++)
|
||||
{
|
||||
var regIdx = i / 2;
|
||||
var highByte = data[regIdx * 2];
|
||||
var lowByte = data[regIdx * 2 + 1];
|
||||
byte b;
|
||||
if (tag.StringByteOrder == ModbusStringByteOrder.HighByteFirst)
|
||||
b = (i % 2 == 0) ? highByte : lowByte;
|
||||
else
|
||||
b = (i % 2 == 0) ? lowByte : highByte;
|
||||
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.Bcd16:
|
||||
{
|
||||
var v = Convert.ToUInt32(value);
|
||||
if (v > 9999) throw new OverflowException($"BCD16 value {v} exceeds 4 decimal digits");
|
||||
var raw = (ushort)EncodeBcd(v, nibbles: 4);
|
||||
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, raw); return b;
|
||||
}
|
||||
case ModbusDataType.Bcd32:
|
||||
{
|
||||
var v = Convert.ToUInt32(value);
|
||||
if (v > 99_999_999u) throw new OverflowException($"BCD32 value {v} exceeds 8 decimal digits");
|
||||
var raw = EncodeBcd(v, nibbles: 8);
|
||||
var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, raw);
|
||||
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||
}
|
||||
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++)
|
||||
{
|
||||
var regIdx = i / 2;
|
||||
var destIdx = tag.StringByteOrder == ModbusStringByteOrder.HighByteFirst
|
||||
? (i % 2 == 0 ? regIdx * 2 : regIdx * 2 + 1)
|
||||
: (i % 2 == 0 ? regIdx * 2 + 1 : regIdx * 2);
|
||||
b[destIdx] = (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,
|
||||
ModbusDataType.Bcd16 or ModbusDataType.Bcd32 => DriverDataType.Int32,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Decode an N-nibble binary-coded-decimal value. Each nibble of <paramref name="raw"/>
|
||||
/// encodes one decimal digit (most-significant nibble first). Rejects nibbles > 9 —
|
||||
/// the hardware sometimes produces garbage during transitions and silent non-BCD reads
|
||||
/// would quietly corrupt the caller's data.
|
||||
/// </summary>
|
||||
internal static uint DecodeBcd(uint raw, int nibbles)
|
||||
{
|
||||
uint result = 0;
|
||||
for (var i = nibbles - 1; i >= 0; i--)
|
||||
{
|
||||
var digit = (raw >> (i * 4)) & 0xF;
|
||||
if (digit > 9)
|
||||
throw new InvalidDataException(
|
||||
$"Non-BCD nibble 0x{digit:X} at position {i} of raw=0x{raw:X}");
|
||||
result = result * 10 + digit;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode a decimal value as N-nibble BCD. Caller is responsible for range-checking
|
||||
/// against the nibble capacity (10^nibbles - 1).
|
||||
/// </summary>
|
||||
internal static uint EncodeBcd(uint value, int nibbles)
|
||||
{
|
||||
uint result = 0;
|
||||
for (var i = 0; i < nibbles; i++)
|
||||
{
|
||||
var digit = value % 10;
|
||||
result |= digit << (i * 4);
|
||||
value /= 10;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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;
|
||||
private const uint StatusBadOutOfRange = 0x803C0000u;
|
||||
private const uint StatusBadNotSupported = 0x803D0000u;
|
||||
private const uint StatusBadDeviceFailure = 0x80550000u;
|
||||
private const uint StatusBadCommunicationError = 0x80050000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map a server-returned Modbus exception code to the most informative OPC UA
|
||||
/// StatusCode. Keeps the driver's outward-facing status surface aligned with what a
|
||||
/// Modbus engineer would expect when reading the spec: exception 02 (Illegal Data
|
||||
/// Address) surfaces as BadOutOfRange so clients can distinguish "tag wrong" from
|
||||
/// generic BadInternalError, exception 04 (Server Failure) as BadDeviceFailure so
|
||||
/// operators see a CPU-mode problem rather than a driver bug, etc. Per
|
||||
/// <c>docs/v2/dl205.md</c>, DL205/DL260 returns only codes 01-04 — no proprietary
|
||||
/// extensions.
|
||||
/// </summary>
|
||||
internal static uint MapModbusExceptionToStatus(byte exceptionCode) => exceptionCode switch
|
||||
{
|
||||
0x01 => StatusBadNotSupported, // Illegal Function — FC not in supported list
|
||||
0x02 => StatusBadOutOfRange, // Illegal Data Address — register outside mapped range
|
||||
0x03 => StatusBadOutOfRange, // Illegal Data Value — quantity over per-FC cap
|
||||
0x04 => StatusBadDeviceFailure, // Server Failure — CPU in PROGRAM mode during protected write
|
||||
0x05 or 0x06 => StatusBadDeviceFailure, // Acknowledge / Server Busy — long-running op / busy
|
||||
0x0A or 0x0B => StatusBadCommunicationError, // Gateway path unavailable / target failed to respond
|
||||
_ => StatusBadInternalError,
|
||||
};
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
|
||||
_transport = null;
|
||||
}
|
||||
}
|
||||
150
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs
Normal file
150
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// Maximum registers per FC03 (Read Holding Registers) / FC04 (Read Input Registers)
|
||||
/// transaction. Modbus-TCP spec allows 125; many device families impose lower caps:
|
||||
/// AutomationDirect DL205/DL260 cap at <c>128</c>, Mitsubishi Q/FX3U cap at <c>64</c>,
|
||||
/// Omron CJ/CS cap at <c>125</c>. Set to the lowest cap across the devices this driver
|
||||
/// instance talks to; the driver auto-chunks larger reads into consecutive requests.
|
||||
/// Default <c>125</c> — the spec maximum, safe against any conforming server. Setting
|
||||
/// to <c>0</c> disables the cap (discouraged — the spec upper bound still applies).
|
||||
/// </summary>
|
||||
public ushort MaxRegistersPerRead { get; init; } = 125;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum registers per FC16 (Write Multiple Registers) transaction. Spec maximum is
|
||||
/// <c>123</c>; DL205/DL260 cap at <c>100</c>. Matching caller-vs-device semantics:
|
||||
/// exceeding the cap currently throws (writes aren't auto-chunked because a partial
|
||||
/// write across two FC16 calls is no longer atomic — caller must explicitly opt in
|
||||
/// by shortening the tag's <c>StringLength</c> or splitting it into multiple tags).
|
||||
/// </summary>
|
||||
public ushort MaxRegistersPerWrite { get; init; } = 123;
|
||||
}
|
||||
|
||||
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>
|
||||
/// <param name="StringByteOrder">
|
||||
/// Per-register byte order for <c>DataType = String</c>. Standard Modbus packs the first
|
||||
/// character in the high byte (<see cref="ModbusStringByteOrder.HighByteFirst"/>).
|
||||
/// AutomationDirect DirectLOGIC (DL205/DL260) and a few legacy families pack the first
|
||||
/// character in the low byte instead — see <c>docs/v2/dl205.md</c> §strings.
|
||||
/// </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,
|
||||
ModbusStringByteOrder StringByteOrder = ModbusStringByteOrder.HighByteFirst);
|
||||
|
||||
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>
|
||||
/// 16-bit binary-coded decimal. Each nibble encodes one decimal digit (0-9). Register
|
||||
/// value <c>0x1234</c> decodes as decimal <c>1234</c> — NOT binary <c>0x04D2 = 4660</c>.
|
||||
/// DL205/DL260 and several Mitsubishi / Omron families store timers, counters, and
|
||||
/// operator-facing numerics as BCD by default.
|
||||
/// </summary>
|
||||
Bcd16,
|
||||
/// <summary>
|
||||
/// 32-bit (two-register) BCD. Decodes 8 decimal digits. Word ordering follows
|
||||
/// <see cref="ModbusTagDefinition.ByteOrder"/> the same way <see cref="Int32"/> does.
|
||||
/// </summary>
|
||||
Bcd32,
|
||||
}
|
||||
|
||||
/// <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,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-register byte order for ASCII strings packed 2 chars per register. Standard Modbus
|
||||
/// convention is <see cref="HighByteFirst"/> — the first character of each pair occupies
|
||||
/// the high byte of the register. AutomationDirect DirectLOGIC (DL205, DL260, DL350) and a
|
||||
/// handful of legacy controllers pack <see cref="LowByteFirst"/>, which inverts that within
|
||||
/// each register. Word ordering across multiple registers is always ascending address for
|
||||
/// strings — only the byte order inside each register flips.
|
||||
/// </summary>
|
||||
public enum ModbusStringByteOrder
|
||||
{
|
||||
HighByteFirst,
|
||||
LowByteFirst,
|
||||
}
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user