Compare commits
44 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ packages/
|
||||
# Claude Code (per-developer settings, runtime lock files, agent transcripts)
|
||||
.claude/
|
||||
|
||||
.local/
|
||||
|
||||
@@ -21,9 +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.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.
|
||||
@@ -7,25 +7,50 @@ Basic256Sha256 endpoints and alarms are observable through
|
||||
specific before the stack can fully replace the v1 deployment, in
|
||||
rough priority order.
|
||||
|
||||
## 1. Proxy-side `IHistoryProvider` for `ReadAtTime` / `ReadEvents`
|
||||
## 1. Proxy-side `IHistoryProvider` for `ReadAtTime` / `ReadEvents` — **DONE (PRs 35 + 38)**
|
||||
|
||||
**Status**: Host-side IPC shipped (PR 10 + PR 11). Proxy consumer not written.
|
||||
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 10 added `HistoryReadAtTimeRequest/Response` on the IPC wire and
|
||||
`MxAccessGalaxyBackend.HistoryReadAtTimeAsync` delegates to
|
||||
`HistorianDataSource.ReadAtTimeAsync`. PR 11 did the same for events
|
||||
(`HistoryReadEventsRequest/Response` + `GalaxyHistoricalEvent`). The Proxy
|
||||
side (`GalaxyProxyDriver`) doesn't call those yet — `Core.Abstractions.IHistoryProvider`
|
||||
only exposes `ReadRawAsync` + `ReadProcessedAsync`.
|
||||
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.
|
||||
|
||||
**To do**:
|
||||
- Extend `IHistoryProvider` with `ReadAtTimeAsync(string, DateTime[], …)` and
|
||||
`ReadEventsAsync(string?, DateTime, DateTime, int, …)`.
|
||||
- `GalaxyProxyDriver` calls the new IPC message kinds.
|
||||
- `DriverNodeManager` wires the new capability methods onto `HistoryRead`
|
||||
`AtTime` + `Events` service handlers.
|
||||
- Integration test: OPC UA client calls `HistoryReadAtTime` / `HistoryReadEvents`,
|
||||
value flows through IPC to the Host's `HistorianDataSource`, back to the client.
|
||||
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)**
|
||||
|
||||
@@ -58,52 +83,113 @@ 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
|
||||
## 4. Live-LDAP integration test — **DONE (PR 31)**
|
||||
|
||||
**Status**: PR 19 unit-tested the auth-flow shape; the live bind path is
|
||||
exercised only by the pre-existing `Admin.Tests/LdapLiveBindTests.cs` which
|
||||
uses the same Novell library against a running GLAuth at `localhost:3893`.
|
||||
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.
|
||||
|
||||
**To do**:
|
||||
- Add `OpcUaServerIntegrationTests.Valid_username_authenticates_against_live_ldap`
|
||||
with the same skip-when-unreachable guard.
|
||||
- Assert `session.Identity` on the server side carries the expected role
|
||||
after bind — requires exposing a test hook or reading identity from a
|
||||
new `IHostConnectivityProbe`-style "whoami" variable in the address space.
|
||||
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.
|
||||
|
||||
## 5. Full Galaxy live-service smoke test against the merged v2 stack
|
||||
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.
|
||||
|
||||
**Status**: Individual pieces have live smoke tests (PR 5 MXAccess, PR 13
|
||||
probe manager, PR 14 alarm tracker), but the full loop — OPC UA client →
|
||||
`OtOpcUaServer` → `GalaxyProxyDriver` (in-process) → named-pipe to
|
||||
Galaxy.Host subprocess → live MXAccess runtime → real Galaxy objects — has
|
||||
no single end-to-end smoke test.
|
||||
## 5. Full Galaxy live-service smoke test against the merged v2 stack — **IN PROGRESS (PRs 36 + 37)**
|
||||
|
||||
**To do**:
|
||||
- Test that spawns the full topology, discovers a deployed Galaxy object,
|
||||
subscribes to one of its attributes, writes a value back, and asserts the
|
||||
write round-tripped through MXAccess. Skip when ArchestrA isn't running.
|
||||
PR 36 shipped the prerequisites helper (`AvevaPrerequisites`) that probes
|
||||
every dependency a live smoke test needs and produces actionable skip
|
||||
messages.
|
||||
|
||||
## 6. Second driver instance on the same server
|
||||
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:
|
||||
|
||||
**Status**: `DriverHost.RegisterAsync` supports multiple drivers; the OPC UA
|
||||
server creates one `DriverNodeManager` per driver and isolates their
|
||||
subtrees under distinct namespace URIs. Not proven with two active
|
||||
`GalaxyProxyDriver` instances pointing at different Galaxies.
|
||||
- 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.
|
||||
|
||||
**To do**:
|
||||
- Integration test that registers two driver instances, each with a distinct
|
||||
`DriverInstanceId` + endpoint in its own session, asserts nodes from both
|
||||
appear under the correct subtrees, alarm events land on the correct
|
||||
instance's condition nodes.
|
||||
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).
|
||||
|
||||
## 7. Host-status per-AppEngine granularity → Admin UI dashboard
|
||||
**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`.
|
||||
|
||||
**Status**: PR 13 ships per-platform/per-AppEngine `ScanState` probing; PR 17
|
||||
surfaces the resulting `OnHostStatusChanged` events through OPC UA. Admin
|
||||
UI doesn't render a per-host dashboard yet.
|
||||
**Run the live tests** (from a NORMAL non-admin PowerShell):
|
||||
|
||||
**To do**:
|
||||
- SignalR hub push of `HostStatusChangedEventArgs` to the Admin UI.
|
||||
- Dashboard page showing each tracked host, current state, last transition
|
||||
time, failure count.
|
||||
```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.
|
||||
|
||||
@@ -6,60 +6,68 @@ routing against a textbook Modbus server. That's necessary but not sufficient: r
|
||||
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. It's what gets wired up in the
|
||||
`tests/Driver.Modbus.IntegrationTests` project when we ship that (PR 26 candidate).
|
||||
This doc is the harness-and-quirks playbook. The project it describes lives at
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/` — scaffolded in PR 30 with
|
||||
the simulator fixture, DL205 profile stub, and one write/read smoke test. Each
|
||||
confirmed DL205 quirk lands in a follow-up PR as a named test in that project.
|
||||
|
||||
## Harness
|
||||
|
||||
**Chosen simulator: ModbusPal** (Java, scriptable). Rationale:
|
||||
- Scriptable enough to mimic device-specific behaviors (non-standard register
|
||||
layouts, custom exception codes, intentional response delays).
|
||||
- Runs locally, no CI dependency. Tests skip when `localhost:502` (or the configured
|
||||
simulator endpoint) isn't reachable.
|
||||
- Free + long-maintained — physical PLC bench is unavailable in most dev
|
||||
environments, and renting cloud PLCs isn't worth the per-test cost.
|
||||
**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:
|
||||
|
||||
**Setup pattern** (not yet codified in a script — will land alongside the integration
|
||||
test project):
|
||||
1. Install ModbusPal, load the per-device `.xmpp` profile from
|
||||
`tests/Driver.Modbus.IntegrationTests/ModbusPal/` (TBD directory).
|
||||
2. Start the simulator listening on `localhost:502` (or override via
|
||||
`MODBUS_SIM_ENDPOINT` env var).
|
||||
3. `dotnet test` the integration project — tests auto-skip when the endpoint is
|
||||
unreachable, so forgetting to start the simulator doesn't wedge CI.
|
||||
- **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
|
||||
### AutomationDirect DL205 / DL260
|
||||
|
||||
First known target device. Quirks to document and cover with named tests (to be
|
||||
filled in when user validates each behavior in ModbusPal with a DL205 profile):
|
||||
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.
|
||||
|
||||
- **Word order for 32-bit values**: _pending_ — confirm whether DL205 uses ABCD
|
||||
(Modbus TCP standard) or CDAB (Siemens-style word-swap) for Int32/UInt32/Float32.
|
||||
Test name: `DL205_Float32_word_order_is_CDAB` (or `ABCD`, whichever proves out).
|
||||
- **Register-zero access**: _pending_ — some DL205 configurations reject FC03 at
|
||||
register 0 with exception code 02 (illegal data address). If confirmed, the
|
||||
integration test suite verifies `ModbusProbeOptions.ProbeAddress` default of 0
|
||||
triggers the rejection and operators must override; test name:
|
||||
`DL205_FC03_at_register_0_returns_IllegalDataAddress`.
|
||||
- **Coil addressing base**: _pending_ — DL205 documentation sometimes uses 1-based
|
||||
coil addresses; verify the driver's zero-based addressing matches the physical
|
||||
PLC without an off-by-one adjustment.
|
||||
- **Maximum registers per FC03**: _pending_ — Modbus spec caps at 125; some DL205
|
||||
models enforce a lower limit (e.g., 64). Test name:
|
||||
`DL205_FC03_beyond_max_registers_returns_IllegalDataValue`.
|
||||
- **Response framing under sustained load**: _pending_ — the driver's
|
||||
single-flight semaphore assumes the server pairs requests/responses by
|
||||
transaction id; at least one DL205 firmware revision is reported to drop the
|
||||
TxId under load. If reproduced in ModbusPal we add a retry + log-and-continue
|
||||
path to `ModbusTcpTransport`.
|
||||
- **Exception code on coil write to a protected bit**: _pending_ — some DL205
|
||||
setups protect internal coils; the driver should surface the PLC's exception
|
||||
PDU as `BadNotWritable` rather than `BadInternalError`.
|
||||
Confirmed quirks (priority order — top items are highest-impact for our driver
|
||||
and ship first as PR 41+):
|
||||
|
||||
_User action item_: as each quirk is validated in ModbusPal, replace the _pending_
|
||||
marker with the confirmed behavior and file a named test in the integration suite.
|
||||
| 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
|
||||
|
||||
@@ -87,17 +95,27 @@ vendors get promoted into driver defaults or opt-in options:
|
||||
protocol end-to-end. The in-memory `FakeTransport` from the unit test suite is
|
||||
deliberately not used here — its value is speed + determinism, which doesn't
|
||||
help reproduce device-specific issues.
|
||||
- **Don't depend on ModbusPal state between tests.** Each test resets the
|
||||
- **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.
|
||||
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 26 — Integration test project + DL205 profile scaffold**: creates
|
||||
`tests/Driver.Modbus.IntegrationTests`, imports the ModbusPal profile (or
|
||||
generates it from JSON), adds the fixture with skip-when-unreachable, plus
|
||||
one smoke test that reads a register. No DL205-specific assertions yet — that
|
||||
waits for the user to validate each quirk.
|
||||
- **PR 27+**: one PR per confirmed DL205 quirk, landing the named test + any
|
||||
driver-side adjustment (e.g., retry on dropped TxId) needed to pass it.
|
||||
- **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`.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<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>
|
||||
|
||||
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,7 @@ 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
|
||||
|
||||
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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -339,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;
|
||||
}
|
||||
}
|
||||
@@ -171,11 +171,14 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
{
|
||||
var quantity = RegisterCount(tag);
|
||||
var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
||||
var pdu = new byte[] { fc, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
|
||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
// resp = [fc][byte-count][data...]
|
||||
var data = new ReadOnlySpan<byte>(resp, 2, resp[1]);
|
||||
// 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:
|
||||
@@ -183,6 +186,33 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -239,8 +269,13 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
}
|
||||
else
|
||||
{
|
||||
// FC 16 (Write Multiple Registers) for 32-bit types
|
||||
// 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);
|
||||
@@ -404,8 +439,8 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
/// </summary>
|
||||
internal static ushort RegisterCount(ModbusTagDefinition tag) => tag.DataType switch
|
||||
{
|
||||
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister => 1,
|
||||
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 => 2,
|
||||
ModbusDataType.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}"),
|
||||
@@ -435,6 +470,17 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
{
|
||||
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);
|
||||
@@ -472,13 +518,21 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
}
|
||||
case ModbusDataType.String:
|
||||
{
|
||||
// ASCII, 2 chars per register, packed high byte = first char.
|
||||
// Respect the caller's StringLength (truncate nul-padded regions).
|
||||
// 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 b = data[i];
|
||||
if (b == 0) { return new string(chars, 0, 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);
|
||||
@@ -502,6 +556,21 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
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);
|
||||
@@ -543,7 +612,14 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
var s = Convert.ToString(value) ?? string.Empty;
|
||||
var regs = (tag.StringLength + 1) / 2;
|
||||
var b = new byte[regs * 2];
|
||||
for (var i = 0; i < tag.StringLength && i < s.Length; i++) b[i] = (byte)s[i];
|
||||
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;
|
||||
}
|
||||
@@ -564,9 +640,46 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
|
||||
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");
|
||||
|
||||
|
||||
@@ -25,6 +25,26 @@ public sealed class ModbusDriverOptions
|
||||
/// <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
|
||||
@@ -55,6 +75,12 @@ public sealed class ModbusProbeOptions
|
||||
/// <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,
|
||||
@@ -63,7 +89,8 @@ public sealed record ModbusTagDefinition(
|
||||
bool Writable = true,
|
||||
ModbusByteOrder ByteOrder = ModbusByteOrder.BigEndian,
|
||||
byte BitIndex = 0,
|
||||
ushort StringLength = 0);
|
||||
ushort StringLength = 0,
|
||||
ModbusStringByteOrder StringByteOrder = ModbusStringByteOrder.HighByteFirst);
|
||||
|
||||
public enum ModbusRegion { Coils, DiscreteInputs, InputRegisters, HoldingRegisters }
|
||||
|
||||
@@ -82,6 +109,18 @@ public enum ModbusDataType
|
||||
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>
|
||||
@@ -95,3 +134,17 @@ 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,
|
||||
}
|
||||
|
||||
@@ -28,10 +28,20 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
_client = new TcpClient();
|
||||
// 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(_host, _port, cts.Token).ConfigureAwait(false);
|
||||
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
|
||||
_stream = _client.GetStream();
|
||||
}
|
||||
|
||||
|
||||
143
src/ZB.MOM.WW.OtOpcUa.Server/HostStatusPublisher.cs
Normal file
143
src/ZB.MOM.WW.OtOpcUa.Server/HostStatusPublisher.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Walks every registered driver once per heartbeat interval, asks each
|
||||
/// <see cref="IHostConnectivityProbe"/>-capable driver for its current
|
||||
/// <see cref="HostConnectivityStatus"/> list, and upserts one
|
||||
/// <see cref="DriverHostStatus"/> row per (NodeId, DriverInstanceId, HostName) into the
|
||||
/// central config DB. Powers the Admin UI's per-host drill-down page (LMX follow-up #7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Polling rather than event-driven: simpler, and matches the cadence the Admin UI
|
||||
/// consumes. An event-subscription optimization (push on <c>OnHostStatusChanged</c> for
|
||||
/// immediate reflection) is a straightforward follow-up but adds lifecycle complexity
|
||||
/// — drivers can be registered after the publisher starts, and subscribing to each
|
||||
/// one's event on register + unsubscribing on unregister requires DriverHost to expose
|
||||
/// lifecycle events it doesn't today.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="DriverHostStatus.LastSeenUtc"/> advances every heartbeat so the Admin UI
|
||||
/// can flag stale rows from a crashed Server process independent of
|
||||
/// <see cref="DriverHostStatus.State"/> — a Faulted publisher that stops heartbeating
|
||||
/// stays Faulted in the DB but its LastSeenUtc ages out, which is the signal
|
||||
/// operators actually want.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the DB is unreachable on a given tick, the publisher logs and moves on — it
|
||||
/// does not retry or buffer. The next heartbeat picks up the current-state snapshot,
|
||||
/// which is more useful than replaying stale transitions after a long outage.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HostStatusPublisher(
|
||||
DriverHost driverHost,
|
||||
NodeOptions nodeOptions,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<HostStatusPublisher> logger) : BackgroundService
|
||||
{
|
||||
internal static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(10);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Wait a short moment at startup so NodeBootstrap's RegisterAsync calls have had a
|
||||
// chance to land. First tick runs immediately after so a freshly-started Server
|
||||
// surfaces its host topology in the Admin UI without waiting a full interval.
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try { await PublishOnceAsync(stoppingToken); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never take down the Server on a publisher failure. Log and continue —
|
||||
// stale-row detection on the Admin side will surface the outage.
|
||||
logger.LogWarning(ex, "Host-status publisher tick failed — will retry next heartbeat");
|
||||
}
|
||||
|
||||
try { await Task.Delay(HeartbeatInterval, stoppingToken); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task PublishOnceAsync(CancellationToken ct)
|
||||
{
|
||||
var driverIds = driverHost.RegisteredDriverIds;
|
||||
if (driverIds.Count == 0) return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
|
||||
foreach (var driverId in driverIds)
|
||||
{
|
||||
var driver = driverHost.GetDriver(driverId);
|
||||
if (driver is not IHostConnectivityProbe probe) continue;
|
||||
|
||||
IReadOnlyList<HostConnectivityStatus> statuses;
|
||||
try { statuses = probe.GetHostStatuses(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Driver {DriverId} GetHostStatuses threw — skipping this tick", driverId);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var status in statuses)
|
||||
{
|
||||
await UpsertAsync(db, driverId, status, now, ct);
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task UpsertAsync(OtOpcUaConfigDbContext db, string driverId,
|
||||
HostConnectivityStatus status, DateTime now, CancellationToken ct)
|
||||
{
|
||||
var mapped = MapState(status.State);
|
||||
var existing = await db.DriverHostStatuses.SingleOrDefaultAsync(r =>
|
||||
r.NodeId == nodeOptions.NodeId
|
||||
&& r.DriverInstanceId == driverId
|
||||
&& r.HostName == status.HostName, ct);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
db.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = nodeOptions.NodeId,
|
||||
DriverInstanceId = driverId,
|
||||
HostName = status.HostName,
|
||||
State = mapped,
|
||||
StateChangedUtc = status.LastChangedUtc,
|
||||
LastSeenUtc = now,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
existing.LastSeenUtc = now;
|
||||
if (existing.State != mapped)
|
||||
{
|
||||
existing.State = mapped;
|
||||
existing.StateChangedUtc = status.LastChangedUtc;
|
||||
}
|
||||
}
|
||||
|
||||
internal static DriverHostState MapState(HostState state) => state switch
|
||||
{
|
||||
HostState.Running => DriverHostState.Running,
|
||||
HostState.Stopped => DriverHostState.Stopped,
|
||||
HostState.Faulted => DriverHostState.Faulted,
|
||||
_ => DriverHostState.Unknown,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,11 @@ using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
|
||||
// Core.Abstractions defines a type-named HistoryReadResult (driver-side samples + continuation
|
||||
// point) that collides with Opc.Ua.HistoryReadResult (service-layer per-node result). We
|
||||
// assign driver-side results to an explicitly-aliased local and construct only the service
|
||||
// type in the overrides below.
|
||||
using OpcHistoryReadResult = Opc.Ua.HistoryReadResult;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
@@ -71,7 +76,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
NodeId = new NodeId(_driver.DriverInstanceId, NamespaceIndex),
|
||||
BrowseName = new QualifiedName(_driver.DriverInstanceId, NamespaceIndex),
|
||||
DisplayName = new LocalizedText(_driver.DriverInstanceId),
|
||||
EventNotifier = EventNotifiers.None,
|
||||
// Driver root is the conventional event notifier for HistoryReadEvents — clients
|
||||
// request alarm history by targeting it and the node manager routes through
|
||||
// IHistoryProvider.ReadEventsAsync. SubscribeToEvents is also set so live-event
|
||||
// subscriptions (Alarm & Conditions) can point here in a future PR; today the
|
||||
// alarm events are emitted by per-variable AlarmConditionState siblings but a
|
||||
// "subscribe to all events from this driver" path would use this notifier.
|
||||
EventNotifier = (byte)(EventNotifiers.SubscribeToEvents | EventNotifiers.HistoryRead),
|
||||
};
|
||||
|
||||
// Link under Objects folder so clients see the driver subtree at browse root.
|
||||
@@ -122,8 +133,15 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
DisplayName = new LocalizedText(displayName),
|
||||
DataType = MapDataType(attributeInfo.DriverDataType),
|
||||
ValueRank = attributeInfo.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
|
||||
AccessLevel = AccessLevels.CurrentReadOrWrite,
|
||||
UserAccessLevel = AccessLevels.CurrentReadOrWrite,
|
||||
// Historized attributes get the HistoryRead access bit so the stack dispatches
|
||||
// incoming HistoryRead service calls to this node. Without it the base class
|
||||
// returns BadHistoryOperationUnsupported before our per-kind hook ever runs.
|
||||
// HistoryWrite isn't granted — history rewrite is a separate capability the
|
||||
// driver doesn't support today.
|
||||
AccessLevel = (byte)(AccessLevels.CurrentReadOrWrite
|
||||
| (attributeInfo.IsHistorized ? AccessLevels.HistoryRead : 0)),
|
||||
UserAccessLevel = (byte)(AccessLevels.CurrentReadOrWrite
|
||||
| (attributeInfo.IsHistorized ? AccessLevels.HistoryRead : 0)),
|
||||
Historizing = attributeInfo.IsHistorized,
|
||||
};
|
||||
_currentFolder.AddChild(v);
|
||||
@@ -384,4 +402,379 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
internal int VariableCount => _variablesByFullRef.Count;
|
||||
internal bool TryGetVariable(string fullRef, out BaseDataVariableState? v)
|
||||
=> _variablesByFullRef.TryGetValue(fullRef, out v!);
|
||||
|
||||
// ===================== HistoryRead service handlers (LMX #1, PR 38) =====================
|
||||
//
|
||||
// Wires the driver's IHistoryProvider capability (PR 35 added ReadAtTimeAsync / ReadEventsAsync
|
||||
// alongside the PR 19 ReadRawAsync / ReadProcessedAsync) to the OPC UA HistoryRead service.
|
||||
// CustomNodeManager2 has four protected per-kind hooks; the base dispatches to the right one
|
||||
// based on the concrete HistoryReadDetails subtype. Each hook is sync-returning-void — the
|
||||
// per-driver async calls are bridged via GetAwaiter().GetResult(), matching the pattern
|
||||
// OnReadValue / OnWriteValue already use in this class so HistoryRead doesn't introduce a
|
||||
// different sync-over-async convention.
|
||||
//
|
||||
// Per-node routing: every HistoryReadValueId in nodesToRead has a NodeHandle in
|
||||
// nodesToProcess; the NodeHandle's NodeId.Identifier is the driver-side full reference
|
||||
// (set during Variable() registration) so we can dispatch straight to IHistoryProvider
|
||||
// without a second lookup. Nodes without IHistoryProvider backing (drivers that don't
|
||||
// implement the capability) surface BadHistoryOperationUnsupported per slot and the
|
||||
// rest of the batch continues — same failure-isolation pattern as OnWriteValue.
|
||||
//
|
||||
// Continuation-point handling is pass-through only in this PR: the driver returns null
|
||||
// from its ContinuationPoint field today so the outer result's ContinuationPoint stays
|
||||
// empty. Full Session.SaveHistoryContinuationPoint plumbing is a follow-up when a driver
|
||||
// actually needs paging — the dispatch shape doesn't change, only the result-population.
|
||||
|
||||
private IHistoryProvider? History => _driver as IHistoryProvider;
|
||||
|
||||
protected override void HistoryReadRawModified(
|
||||
ServerSystemContext context, ReadRawModifiedDetails details, TimestampsToReturn timestamps,
|
||||
IList<HistoryReadValueId> nodesToRead, IList<OpcHistoryReadResult> results,
|
||||
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
if (History is null)
|
||||
{
|
||||
MarkAllUnsupported(nodesToProcess, results, errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// IsReadModified=true requests a "modifications" history (who changed the data, when
|
||||
// it was re-written). The driver side has no modifications store — surface that
|
||||
// explicitly rather than silently returning raw data, which would mislead the client.
|
||||
if (details.IsReadModified)
|
||||
{
|
||||
MarkAllUnsupported(nodesToProcess, results, errors, StatusCodes.BadHistoryOperationUnsupported);
|
||||
return;
|
||||
}
|
||||
|
||||
for (var n = 0; n < nodesToProcess.Count; n++)
|
||||
{
|
||||
var handle = nodesToProcess[n];
|
||||
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
|
||||
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
|
||||
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
|
||||
// are interleaved across multiple node managers.
|
||||
var i = handle.Index;
|
||||
var fullRef = ResolveFullRef(handle);
|
||||
if (fullRef is null)
|
||||
{
|
||||
WriteNodeIdUnknown(results, errors, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var driverResult = History.ReadRawAsync(
|
||||
fullRef,
|
||||
details.StartTime,
|
||||
details.EndTime,
|
||||
details.NumValuesPerNode,
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
WriteResult(results, errors, i, StatusCodes.Good,
|
||||
BuildHistoryData(driverResult.Samples), driverResult.ContinuationPoint);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
WriteUnsupported(results, errors, i);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HistoryReadRaw failed for {FullRef}", fullRef);
|
||||
WriteInternalError(results, errors, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void HistoryReadProcessed(
|
||||
ServerSystemContext context, ReadProcessedDetails details, TimestampsToReturn timestamps,
|
||||
IList<HistoryReadValueId> nodesToRead, IList<OpcHistoryReadResult> results,
|
||||
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
if (History is null)
|
||||
{
|
||||
MarkAllUnsupported(nodesToProcess, results, errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// AggregateType is one NodeId shared across every item in the batch — map once.
|
||||
var aggregate = MapAggregate(details.AggregateType?.FirstOrDefault());
|
||||
if (aggregate is null)
|
||||
{
|
||||
MarkAllUnsupported(nodesToProcess, results, errors, StatusCodes.BadAggregateNotSupported);
|
||||
return;
|
||||
}
|
||||
|
||||
var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval);
|
||||
for (var n = 0; n < nodesToProcess.Count; n++)
|
||||
{
|
||||
var handle = nodesToProcess[n];
|
||||
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
|
||||
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
|
||||
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
|
||||
// are interleaved across multiple node managers.
|
||||
var i = handle.Index;
|
||||
var fullRef = ResolveFullRef(handle);
|
||||
if (fullRef is null)
|
||||
{
|
||||
WriteNodeIdUnknown(results, errors, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var driverResult = History.ReadProcessedAsync(
|
||||
fullRef,
|
||||
details.StartTime,
|
||||
details.EndTime,
|
||||
interval,
|
||||
aggregate.Value,
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
WriteResult(results, errors, i, StatusCodes.Good,
|
||||
BuildHistoryData(driverResult.Samples), driverResult.ContinuationPoint);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
WriteUnsupported(results, errors, i);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HistoryReadProcessed failed for {FullRef}", fullRef);
|
||||
WriteInternalError(results, errors, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void HistoryReadAtTime(
|
||||
ServerSystemContext context, ReadAtTimeDetails details, TimestampsToReturn timestamps,
|
||||
IList<HistoryReadValueId> nodesToRead, IList<OpcHistoryReadResult> results,
|
||||
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
if (History is null)
|
||||
{
|
||||
MarkAllUnsupported(nodesToProcess, results, errors);
|
||||
return;
|
||||
}
|
||||
|
||||
var requestedTimes = (IReadOnlyList<DateTime>)(details.ReqTimes?.ToArray() ?? Array.Empty<DateTime>());
|
||||
for (var n = 0; n < nodesToProcess.Count; n++)
|
||||
{
|
||||
var handle = nodesToProcess[n];
|
||||
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
|
||||
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
|
||||
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
|
||||
// are interleaved across multiple node managers.
|
||||
var i = handle.Index;
|
||||
var fullRef = ResolveFullRef(handle);
|
||||
if (fullRef is null)
|
||||
{
|
||||
WriteNodeIdUnknown(results, errors, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var driverResult = History.ReadAtTimeAsync(
|
||||
fullRef, requestedTimes, CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
WriteResult(results, errors, i, StatusCodes.Good,
|
||||
BuildHistoryData(driverResult.Samples), driverResult.ContinuationPoint);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
WriteUnsupported(results, errors, i);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HistoryReadAtTime failed for {FullRef}", fullRef);
|
||||
WriteInternalError(results, errors, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void HistoryReadEvents(
|
||||
ServerSystemContext context, ReadEventDetails details, TimestampsToReturn timestamps,
|
||||
IList<HistoryReadValueId> nodesToRead, IList<OpcHistoryReadResult> results,
|
||||
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
if (History is null)
|
||||
{
|
||||
MarkAllUnsupported(nodesToProcess, results, errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// SourceName filter extraction is deferred — EventFilter SelectClauses + WhereClause
|
||||
// handling is a dedicated concern (proper per-select-clause Variant population + where
|
||||
// filter evaluation). This PR treats the event query as "all events in range for the
|
||||
// node's source" and populates only the standard BaseEventType fields. Richer filter
|
||||
// handling is a follow-up; clients issuing empty/default filters get the right answer
|
||||
// today which covers the common alarm-history browse case.
|
||||
var maxEvents = (int)details.NumValuesPerNode;
|
||||
if (maxEvents <= 0) maxEvents = 1000;
|
||||
|
||||
for (var n = 0; n < nodesToProcess.Count; n++)
|
||||
{
|
||||
var handle = nodesToProcess[n];
|
||||
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
|
||||
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
|
||||
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
|
||||
// are interleaved across multiple node managers.
|
||||
var i = handle.Index;
|
||||
// Event history queries may target a notifier object (e.g. the driver-root folder)
|
||||
// rather than a specific variable — in that case we pass sourceName=null to mean
|
||||
// "all sources in the driver's namespace" per the IHistoryProvider contract.
|
||||
var fullRef = ResolveFullRef(handle);
|
||||
|
||||
try
|
||||
{
|
||||
var driverResult = History.ReadEventsAsync(
|
||||
sourceName: fullRef,
|
||||
startUtc: details.StartTime,
|
||||
endUtc: details.EndTime,
|
||||
maxEvents: maxEvents,
|
||||
cancellationToken: CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
WriteResult(results, errors, i, StatusCodes.Good,
|
||||
BuildHistoryEvent(driverResult.Events), driverResult.ContinuationPoint);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
WriteUnsupported(results, errors, i);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HistoryReadEvents failed for {FullRef}", fullRef);
|
||||
WriteInternalError(results, errors, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string? ResolveFullRef(NodeHandle handle) => handle.NodeId?.Identifier as string;
|
||||
|
||||
// Both the results list AND the parallel errors list must be populated — MasterNodeManager
|
||||
// merges them and the merged StatusCode is what the client sees. Leaving errors[i] at its
|
||||
// default (BadHistoryOperationUnsupported) overrides a Good result with Unsupported, which
|
||||
// masks a correctly-constructed HistoryData response. This was the subtle failure mode
|
||||
// that cost most of PR 38's debugging budget.
|
||||
private static void WriteResult(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors,
|
||||
int i, uint statusCode, ExtensionObject historyData, byte[]? continuationPoint)
|
||||
{
|
||||
results[i] = new OpcHistoryReadResult
|
||||
{
|
||||
StatusCode = statusCode,
|
||||
HistoryData = historyData,
|
||||
ContinuationPoint = continuationPoint,
|
||||
};
|
||||
errors[i] = statusCode == StatusCodes.Good
|
||||
? ServiceResult.Good
|
||||
: new ServiceResult(statusCode);
|
||||
}
|
||||
|
||||
private static void WriteUnsupported(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
|
||||
{
|
||||
results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||||
errors[i] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
}
|
||||
|
||||
private static void WriteInternalError(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
|
||||
{
|
||||
results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadInternalError };
|
||||
errors[i] = StatusCodes.BadInternalError;
|
||||
}
|
||||
|
||||
private static void WriteNodeIdUnknown(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
|
||||
{
|
||||
WriteNodeIdUnknown(results, errors, i);
|
||||
errors[i] = StatusCodes.BadNodeIdUnknown;
|
||||
}
|
||||
|
||||
private static void MarkAllUnsupported(
|
||||
List<NodeHandle> nodes, IList<OpcHistoryReadResult> results, IList<ServiceResult> errors,
|
||||
uint statusCode = StatusCodes.BadHistoryOperationUnsupported)
|
||||
{
|
||||
foreach (var handle in nodes)
|
||||
{
|
||||
results[handle.Index] = new OpcHistoryReadResult { StatusCode = statusCode };
|
||||
errors[handle.Index] = statusCode == StatusCodes.Good ? ServiceResult.Good : new ServiceResult(statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map the OPC UA Part 13 aggregate-function NodeId to the driver's
|
||||
/// <see cref="HistoryAggregateType"/>. Internal so the test suite can pin the mapping
|
||||
/// without exposing public API. Returns null for unsupported aggregates so the service
|
||||
/// handler can surface <c>BadAggregateNotSupported</c> on the whole batch.
|
||||
/// </summary>
|
||||
internal static HistoryAggregateType? MapAggregate(NodeId? aggregateNodeId)
|
||||
{
|
||||
if (aggregateNodeId is null) return null;
|
||||
|
||||
// Every AggregateFunction_* identifier is a numeric uint on the Server (0) namespace.
|
||||
// Comparing NodeIds by value handles all the cross-encoding cases (expanded vs plain).
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Average) return HistoryAggregateType.Average;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Minimum) return HistoryAggregateType.Minimum;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Maximum) return HistoryAggregateType.Maximum;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Total) return HistoryAggregateType.Total;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Count) return HistoryAggregateType.Count;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrap driver samples as <c>HistoryData</c> in an <c>ExtensionObject</c> — the on-wire
|
||||
/// shape the OPC UA HistoryRead service expects for raw / processed / at-time reads.
|
||||
/// </summary>
|
||||
internal static ExtensionObject BuildHistoryData(IReadOnlyList<DataValueSnapshot> samples)
|
||||
{
|
||||
var values = new DataValueCollection(samples.Count);
|
||||
foreach (var s in samples) values.Add(ToDataValue(s));
|
||||
return new ExtensionObject(new HistoryData { DataValues = values });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrap driver events as <c>HistoryEvent</c> in an <c>ExtensionObject</c>. Populates
|
||||
/// the minimum BaseEventType field set (SourceName, Message, Severity, Time,
|
||||
/// ReceiveTime, EventId) so clients that request the default
|
||||
/// <c>SimpleAttributeOperand</c> select-clauses see useful data. Custom EventFilter
|
||||
/// SelectClause evaluation is deferred — when a client sends a specific operand list,
|
||||
/// they currently get the standard fields back and ignore the extras. Documented on the
|
||||
/// public follow-up list.
|
||||
/// </summary>
|
||||
internal static ExtensionObject BuildHistoryEvent(IReadOnlyList<HistoricalEvent> events)
|
||||
{
|
||||
var fieldLists = new HistoryEventFieldListCollection(events.Count);
|
||||
foreach (var e in events)
|
||||
{
|
||||
var fields = new VariantCollection
|
||||
{
|
||||
// Order must match BaseEventType's conventional field ordering so clients that
|
||||
// didn't customize the SelectClauses still see recognizable columns. A future
|
||||
// PR that respects the client's SelectClause list will drive this from the filter.
|
||||
new Variant(e.EventId),
|
||||
new Variant(e.SourceName ?? string.Empty),
|
||||
new Variant(new LocalizedText(e.Message ?? string.Empty)),
|
||||
new Variant(e.Severity),
|
||||
new Variant(e.EventTimeUtc),
|
||||
new Variant(e.ReceivedTimeUtc),
|
||||
};
|
||||
fieldLists.Add(new HistoryEventFieldList { EventFields = fields });
|
||||
}
|
||||
return new ExtensionObject(new HistoryEvent { Events = fieldLists });
|
||||
}
|
||||
|
||||
internal static DataValue ToDataValue(DataValueSnapshot s)
|
||||
{
|
||||
var dv = new DataValue
|
||||
{
|
||||
Value = s.Value,
|
||||
StatusCode = new StatusCode(s.StatusCode),
|
||||
ServerTimestamp = s.ServerTimestampUtc,
|
||||
};
|
||||
if (s.SourceTimestampUtc.HasValue) dv.SourceTimestamp = s.SourceTimestampUtc.Value;
|
||||
return dv;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server;
|
||||
@@ -72,5 +74,11 @@ builder.Services.AddSingleton<NodeBootstrap>();
|
||||
builder.Services.AddSingleton<OpcUaApplicationHost>();
|
||||
builder.Services.AddHostedService<OpcUaServerService>();
|
||||
|
||||
// Central-config DB access for the host-status publisher (LMX follow-up #7). Scoped context
|
||||
// so per-heartbeat change-tracking stays isolated; publisher opens one scope per tick.
|
||||
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
||||
opt.UseSqlServer(options.ConfigDbConnectionString));
|
||||
builder.Services.AddHostedService<HostStatusPublisher>();
|
||||
|
||||
var host = builder.Build();
|
||||
await host.RunAsync();
|
||||
|
||||
@@ -2,11 +2,37 @@ namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP settings for the OPC UA server's UserName token validator. Bound from
|
||||
/// <c>appsettings.json</c> <c>OpcUaServer:Ldap</c>. Defaults match the GLAuth dev instance
|
||||
/// (localhost:3893, dc=lmxopcua,dc=local). Production deployments set <see cref="UseTls"/>
|
||||
/// true, populate <see cref="ServiceAccountDn"/> for search-then-bind, and maintain
|
||||
/// <see cref="GroupToRole"/> with the real LDAP group names.
|
||||
/// <c>appsettings.json</c> <c>OpcUaServer:Ldap</c>. Defaults target the GLAuth dev instance
|
||||
/// (localhost:3893, <c>dc=lmxopcua,dc=local</c>) for the stock inner-loop setup. Production
|
||||
/// deployments are expected to point at Active Directory; see <see cref="UserNameAttribute"/>
|
||||
/// and the per-field xml-docs for the AD-specific overrides.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Active Directory cheat-sheet</b>:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="Server"/>: one of the domain controllers, or the domain FQDN (will round-robin DCs).</item>
|
||||
/// <item><see cref="Port"/>: <c>389</c> (LDAP) or <c>636</c> (LDAPS); use 636 + <see cref="UseTls"/> in production.</item>
|
||||
/// <item><see cref="UseTls"/>: <c>true</c>. AD increasingly rejects plain-LDAP bind under LDAP-signing enforcement.</item>
|
||||
/// <item><see cref="AllowInsecureLdap"/>: <c>false</c>. Dev escape hatch only.</item>
|
||||
/// <item><see cref="SearchBase"/>: <c>DC=corp,DC=example,DC=com</c> — your domain's base DN.</item>
|
||||
/// <item><see cref="ServiceAccountDn"/>: a dedicated service principal with read access to user + group entries
|
||||
/// (e.g. <c>CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com</c>). Never a privileged admin.</item>
|
||||
/// <item><see cref="UserNameAttribute"/>: <c>sAMAccountName</c> (classic login name) or <c>userPrincipalName</c>
|
||||
/// (user@domain form). Default is <c>uid</c> which AD does <b>not</b> populate, so this override is required.</item>
|
||||
/// <item><see cref="DisplayNameAttribute"/>: <c>displayName</c> gives the human name; <c>cn</c> works too but is less rich.</item>
|
||||
/// <item><see cref="GroupAttribute"/>: <c>memberOf</c> — matches AD's default. Values are full DNs
|
||||
/// (<c>CN=<Group>,OU=...,DC=...</c>); the authenticator strips the leading <c>CN=</c> RDN value and uses
|
||||
/// that as the lookup key in <see cref="GroupToRole"/>.</item>
|
||||
/// <item><see cref="GroupToRole"/>: maps your AD group common-names to OPC UA roles — e.g.
|
||||
/// <c>{"OPCUA-Operators" : "WriteOperate", "OPCUA-Engineers" : "WriteConfigure"}</c>.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Nested groups are <b>not</b> expanded — AD's <c>tokenGroups</c> / <c>LDAP_MATCHING_RULE_IN_CHAIN</c>
|
||||
/// membership-chain filter isn't used. Assign users directly to the role-mapped groups, or pre-flatten
|
||||
/// membership in your directory. If nested expansion becomes a requirement, it's an authenticator
|
||||
/// enhancement (not a config change).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LdapOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = false;
|
||||
@@ -23,6 +49,20 @@ public sealed class LdapOptions
|
||||
public string DisplayNameAttribute { get; init; } = "cn";
|
||||
public string GroupAttribute { get; init; } = "memberOf";
|
||||
|
||||
/// <summary>
|
||||
/// LDAP attribute used to match a login name against user entries in the directory.
|
||||
/// Defaults to <c>uid</c> (RFC 2307). Common overrides:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>sAMAccountName</c> — Active Directory, classic NT-style login names (e.g. <c>jdoe</c>).</item>
|
||||
/// <item><c>userPrincipalName</c> — Active Directory, email-style (e.g. <c>jdoe@corp.example.com</c>).</item>
|
||||
/// <item><c>cn</c> — GLAuth + some OpenLDAP deployments where users are keyed by common-name.</item>
|
||||
/// </list>
|
||||
/// Used only when <see cref="ServiceAccountDn"/> is non-empty (search-then-bind path) —
|
||||
/// direct-bind fallback constructs the DN as <c>cn=<name>,<SearchBase></c>
|
||||
/// regardless of this setting and is not a production-grade path against AD.
|
||||
/// </summary>
|
||||
public string UserNameAttribute { get; init; } = "uid";
|
||||
|
||||
/// <summary>
|
||||
/// LDAP group → OPC UA role. Each authenticated user gets every role whose source group
|
||||
/// is in their membership list. Recognized role names (CLAUDE.md): <c>ReadOnly</c> (browse
|
||||
|
||||
@@ -106,7 +106,7 @@ public sealed class LdapUserAuthenticator(LdapOptions options, ILogger<LdapUserA
|
||||
{
|
||||
await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);
|
||||
|
||||
var filter = $"(uid={EscapeLdapFilter(username)})";
|
||||
var filter = $"({options.UserNameAttribute}={EscapeLdapFilter(username)})";
|
||||
var results = await Task.Run(() =>
|
||||
conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.374.126"/>
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end round-trip through the DB for the <see cref="DriverHostStatus"/> entity
|
||||
/// added in PR 33 — exercises the composite primary key (NodeId, DriverInstanceId,
|
||||
/// HostName), string-backed <c>DriverHostState</c> conversion, and the two indexes the
|
||||
/// Admin UI's drill-down queries will scan (NodeId, LastSeenUtc).
|
||||
/// </summary>
|
||||
[Trait("Category", "SchemaCompliance")]
|
||||
[Collection(nameof(SchemaComplianceCollection))]
|
||||
public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Composite_key_allows_same_host_across_different_nodes_or_drivers()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
|
||||
// Same HostName + DriverInstanceId across two different server nodes — classic 2-node
|
||||
// redundancy case. Both rows must be insertable because each server node owns its own
|
||||
// runtime view of the shared host.
|
||||
var now = DateTime.UtcNow;
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "node-a", DriverInstanceId = "galaxy-1", HostName = "GRPlatform",
|
||||
State = DriverHostState.Running,
|
||||
StateChangedUtc = now, LastSeenUtc = now,
|
||||
});
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "node-b", DriverInstanceId = "galaxy-1", HostName = "GRPlatform",
|
||||
State = DriverHostState.Stopped,
|
||||
StateChangedUtc = now, LastSeenUtc = now,
|
||||
Detail = "secondary hasn't taken over yet",
|
||||
});
|
||||
// Same server node + host, different driver instance — second driver doesn't clobber.
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "node-a", DriverInstanceId = "modbus-plc1", HostName = "GRPlatform",
|
||||
State = DriverHostState.Running,
|
||||
StateChangedUtc = now, LastSeenUtc = now,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var rows = await ctx.DriverHostStatuses.AsNoTracking()
|
||||
.Where(r => r.HostName == "GRPlatform").ToListAsync();
|
||||
|
||||
rows.Count.ShouldBe(3);
|
||||
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "galaxy-1");
|
||||
rows.ShouldContain(r => r.NodeId == "node-b" && r.State == DriverHostState.Stopped && r.Detail == "secondary hasn't taken over yet");
|
||||
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "modbus-plc1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_pattern_for_same_key_updates_in_place()
|
||||
{
|
||||
// The publisher hosted service (follow-up PR) upserts on every transition +
|
||||
// heartbeat. This test pins the two-step pattern it will use: check-then-add-or-update
|
||||
// keyed on the composite PK. If the composite key ever changes, this test breaks
|
||||
// loudly so the publisher gets a synchronized update.
|
||||
await using var ctx = NewContext();
|
||||
var t0 = DateTime.UtcNow;
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "upsert-node", DriverInstanceId = "upsert-driver", HostName = "upsert-host",
|
||||
State = DriverHostState.Running,
|
||||
StateChangedUtc = t0, LastSeenUtc = t0,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var t1 = t0.AddSeconds(30);
|
||||
await using (var ctx2 = NewContext())
|
||||
{
|
||||
var existing = await ctx2.DriverHostStatuses.SingleAsync(r =>
|
||||
r.NodeId == "upsert-node" && r.DriverInstanceId == "upsert-driver" && r.HostName == "upsert-host");
|
||||
existing.State = DriverHostState.Faulted;
|
||||
existing.StateChangedUtc = t1;
|
||||
existing.LastSeenUtc = t1;
|
||||
existing.Detail = "transport reset by peer";
|
||||
await ctx2.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using var ctx3 = NewContext();
|
||||
var final = await ctx3.DriverHostStatuses.AsNoTracking().SingleAsync(r =>
|
||||
r.NodeId == "upsert-node" && r.HostName == "upsert-host");
|
||||
final.State.ShouldBe(DriverHostState.Faulted);
|
||||
final.Detail.ShouldBe("transport reset by peer");
|
||||
// Only one row — a naive "always insert" would have created a duplicate PK and thrown.
|
||||
(await ctx3.DriverHostStatuses.CountAsync(r => r.NodeId == "upsert-node")).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enum_persists_as_string_not_int()
|
||||
{
|
||||
// Fluent config sets HasConversion<string>() on State — the DB stores 'Running' /
|
||||
// 'Stopped' / 'Faulted' / 'Unknown' as nvarchar(16). Verify by reading the raw
|
||||
// string back via ADO; if someone drops the conversion the column will contain '1'
|
||||
// / '2' / '3' and this assertion fails. Matters because DBAs inspecting the table
|
||||
// directly should see readable state names, not enum ordinals.
|
||||
await using var ctx = NewContext();
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "enum-node", DriverInstanceId = "enum-driver", HostName = "enum-host",
|
||||
State = DriverHostState.Faulted,
|
||||
StateChangedUtc = DateTime.UtcNow, LastSeenUtc = DateTime.UtcNow,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
await using var conn = fixture.OpenConnection();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT [State] FROM DriverHostStatus WHERE NodeId = 'enum-node'";
|
||||
var rawValue = (string?)await cmd.ExecuteScalarAsync();
|
||||
rawValue.ShouldBe("Faulted");
|
||||
}
|
||||
|
||||
private OtOpcUaConfigDbContext NewContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseSqlServer(fixture.ConnectionString)
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public sealed class SchemaComplianceTests
|
||||
"Namespace", "UnsArea", "UnsLine",
|
||||
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup",
|
||||
"NodeAcl", "ExternalIdReservation",
|
||||
"DriverHostStatus",
|
||||
};
|
||||
|
||||
var actual = QueryStrings(@"
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Exercises <see cref="AvevaPrerequisites"/> against the live dev box so the helper
|
||||
/// itself gets integration coverage — i.e. "do the probes return Pass for things that
|
||||
/// really are Pass?" as validated against this machine's known-installed topology.
|
||||
/// Category <c>LiveGalaxy</c> so CI / clean dev boxes skip cleanly.
|
||||
/// </summary>
|
||||
[Trait("Category", "LiveGalaxy")]
|
||||
public sealed class AvevaPrerequisitesLiveTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public AvevaPrerequisitesLiveTests(ITestOutputHelper output) => _output = output;
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAll_on_live_box_reports_Framework_install()
|
||||
{
|
||||
var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
_output.WriteLine(report.ToString());
|
||||
report.Checks.ShouldContain(c =>
|
||||
c.Name == "registry:ArchestrA.Framework" && c.Status == PrerequisiteStatus.Pass,
|
||||
"ArchestrA Framework registry root should be found on this machine.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAll_on_live_box_reports_aaBootstrap_running()
|
||||
{
|
||||
var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
var bootstrap = report.Checks.FirstOrDefault(c => c.Name == "service:aaBootstrap");
|
||||
bootstrap.ShouldNotBeNull();
|
||||
bootstrap.Status.ShouldBe(PrerequisiteStatus.Pass,
|
||||
$"aaBootstrap must be Running for any live-Galaxy test to work — detail: {bootstrap.Detail}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAll_on_live_box_reports_aaGR_running()
|
||||
{
|
||||
var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
var gr = report.Checks.FirstOrDefault(c => c.Name == "service:aaGR");
|
||||
gr.ShouldNotBeNull();
|
||||
gr.Status.ShouldBe(PrerequisiteStatus.Pass,
|
||||
$"aaGR (Galaxy Repository) must be Running — detail: {gr.Detail}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAll_on_live_box_reports_MxAccess_COM_registered()
|
||||
{
|
||||
var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
var com = report.Checks.FirstOrDefault(c => c.Name == "com:LMXProxy");
|
||||
com.ShouldNotBeNull();
|
||||
com.Status.ShouldBe(PrerequisiteStatus.Pass,
|
||||
$"LMXProxy.LMXProxyServer ProgID must resolve to an InprocServer32 DLL — detail: {com.Detail}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckRepositoryOnly_on_live_box_reports_ZB_reachable()
|
||||
{
|
||||
var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync(ct: CancellationToken.None);
|
||||
var zb = report.Checks.FirstOrDefault(c => c.Name == "sql:ZB");
|
||||
zb.ShouldNotBeNull();
|
||||
zb.Status.ShouldBe(PrerequisiteStatus.Pass,
|
||||
$"ZB database must be reachable via SQL Server Windows auth — detail: {zb.Detail}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckRepositoryOnly_on_live_box_reports_non_zero_deployed_objects()
|
||||
{
|
||||
// This box has 49 deployed objects per the research; we just assert > 0 so adding/
|
||||
// removing objects doesn't break the test.
|
||||
var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync();
|
||||
var deployed = report.Checks.FirstOrDefault(c => c.Name == "sql:ZB.deployedObjects");
|
||||
deployed.ShouldNotBeNull();
|
||||
deployed.Status.ShouldBe(PrerequisiteStatus.Pass,
|
||||
$"At least one deployed gobject should exist — detail: {deployed.Detail}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aveva_side_is_ready_on_this_machine()
|
||||
{
|
||||
// Narrower than "livetest ready" — our own services (OtOpcUa / OtOpcUaGalaxyHost)
|
||||
// may not be installed on a developer's box while they're actively iterating on
|
||||
// them, but the AVEVA side (Framework / Galaxy Repository / MXAccess COM /
|
||||
// SQL / core services) should always be up on a machine with System Platform
|
||||
// installed. This assertion is what gates live-Galaxy tests that go straight to
|
||||
// the Galaxy Repository without routing through our stack.
|
||||
var report = await AvevaPrerequisites.CheckAllAsync(
|
||||
new AvevaPrerequisites.Options { CheckGalaxyHostPipe = false });
|
||||
_output.WriteLine(report.ToString());
|
||||
_output.WriteLine(report.Warnings ?? "no warnings");
|
||||
|
||||
// Enumerate AVEVA-side failures (if any) for an actionable assertion message.
|
||||
var avevaFails = report.Checks
|
||||
.Where(c => c.Status == PrerequisiteStatus.Fail &&
|
||||
c.Category != PrerequisiteCategory.OtOpcUaService)
|
||||
.ToList();
|
||||
report.IsAvevaSideReady.ShouldBeTrue(
|
||||
avevaFails.Count == 0
|
||||
? "unexpected state"
|
||||
: "AVEVA-side failures: " + string.Join(" ; ",
|
||||
avevaFails.Select(f => $"{f.Name}: {f.Detail}")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Report_captures_OtOpcUa_services_state_even_when_not_installed()
|
||||
{
|
||||
// The helper reports the status of OtOpcUaGalaxyHost + OtOpcUa services even if
|
||||
// they're not installed yet — absence is itself an actionable signal. This test
|
||||
// doesn't assert Pass/Fail on those services (their state depends on what's
|
||||
// installed when the test runs) — it only asserts the helper EMITTED the rows,
|
||||
// so nobody can ship a prerequisite check that silently omits our own services.
|
||||
var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
|
||||
report.Checks.ShouldContain(c => c.Name == "service:OtOpcUaGalaxyHost");
|
||||
report.Checks.ShouldContain(c => c.Name == "service:OtOpcUa");
|
||||
report.Checks.ShouldContain(c => c.Name == "service:GLAuth");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
{
|
||||
@@ -16,6 +17,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
/// SQL the v1 Host uses, proving the lift is byte-for-byte equivalent at the
|
||||
/// <c>DiscoverHierarchyResponse</c> shape.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Since PR 36, skip logic is delegated to <see cref="AvevaPrerequisites.CheckRepositoryOnlyAsync"/>
|
||||
/// so operators see exactly why a test skipped ("ZB db not found" vs "SQL Server
|
||||
/// unreachable") instead of a silent return.
|
||||
/// </remarks>
|
||||
[Trait("Category", "LiveGalaxy")]
|
||||
public sealed class GalaxyRepositoryLiveSmokeTests
|
||||
{
|
||||
@@ -26,15 +32,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
CommandTimeoutSeconds = 10,
|
||||
};
|
||||
|
||||
private static async Task<string?> RepositorySkipReasonAsync()
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(4));
|
||||
var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync(
|
||||
DevZbOptions().ConnectionString, cts.Token);
|
||||
return report.SkipReason;
|
||||
}
|
||||
|
||||
private static async Task<bool> ZbReachableAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var repo = new GalaxyRepository(DevZbOptions());
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
return await repo.TestConnectionAsync(cts.Token);
|
||||
}
|
||||
catch { return false; }
|
||||
// Legacy silent-skip adapter — keeps the existing tests compiling while
|
||||
// gradually migrating to the Skip-with-reason pattern. Returns true when the
|
||||
// prerequisite check has no Fail entries.
|
||||
return (await RepositorySkipReasonAsync()) is null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
|
||||
<Reference Include="System.ServiceProcess"/>
|
||||
<!-- IMxProxy's delegate signatures mention ArchestrA.MxAccess.MXSTATUS_PROXY, so tests
|
||||
implementing the interface must resolve that type at compile time. -->
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="GalaxyProxyDriver.ToHistoricalEvent"/> — the wire-to-domain mapping
|
||||
/// from <see cref="GalaxyHistoricalEvent"/> (MessagePack-annotated IPC contract,
|
||||
/// Unix-ms timestamps) to <c>Core.Abstractions.HistoricalEvent</c> (domain record,
|
||||
/// <see cref="DateTime"/> timestamps). Added in PR 35 alongside the new
|
||||
/// <c>IHistoryProvider.ReadEventsAsync</c> method.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistoricalEventMappingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Maps_every_field_from_wire_to_domain_record()
|
||||
{
|
||||
var wire = new GalaxyHistoricalEvent
|
||||
{
|
||||
EventId = "evt-42",
|
||||
SourceName = "Tank1.HiAlarm",
|
||||
EventTimeUtcUnixMs = 1_700_000_000_000L, // 2023-11-14T22:13:20.000Z
|
||||
ReceivedTimeUtcUnixMs = 1_700_000_000_500L,
|
||||
DisplayText = "High level reached",
|
||||
Severity = 750,
|
||||
};
|
||||
|
||||
var domain = GalaxyProxyDriver.ToHistoricalEvent(wire);
|
||||
|
||||
domain.EventId.ShouldBe("evt-42");
|
||||
domain.SourceName.ShouldBe("Tank1.HiAlarm");
|
||||
domain.EventTimeUtc.ShouldBe(new DateTime(2023, 11, 14, 22, 13, 20, DateTimeKind.Utc));
|
||||
domain.ReceivedTimeUtc.ShouldBe(new DateTime(2023, 11, 14, 22, 13, 20, 500, DateTimeKind.Utc));
|
||||
domain.Message.ShouldBe("High level reached");
|
||||
domain.Severity.ShouldBe((ushort)750);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preserves_null_SourceName_and_DisplayText()
|
||||
{
|
||||
// Historical rows from the Galaxy event historian often omit source or message for
|
||||
// system events (e.g. time sync). The mapping must preserve null — callers use it to
|
||||
// distinguish system events from alarm events.
|
||||
var wire = new GalaxyHistoricalEvent
|
||||
{
|
||||
EventId = "sys-1",
|
||||
SourceName = null,
|
||||
EventTimeUtcUnixMs = 0,
|
||||
ReceivedTimeUtcUnixMs = 0,
|
||||
DisplayText = null,
|
||||
Severity = 1,
|
||||
};
|
||||
|
||||
var domain = GalaxyProxyDriver.ToHistoricalEvent(wire);
|
||||
|
||||
domain.SourceName.ShouldBeNull();
|
||||
domain.Message.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventTime_and_ReceivedTime_are_produced_as_DateTimeKind_Utc()
|
||||
{
|
||||
// Unix-ms timestamps come off the wire timezone-agnostic; the mapping must tag the
|
||||
// resulting DateTime as Utc so downstream serializers (JSON, OPC UA types) don't apply
|
||||
// an unexpected local-time offset.
|
||||
var wire = new GalaxyHistoricalEvent
|
||||
{
|
||||
EventId = "e",
|
||||
EventTimeUtcUnixMs = 1_000L,
|
||||
ReceivedTimeUtcUnixMs = 2_000L,
|
||||
};
|
||||
|
||||
var domain = GalaxyProxyDriver.ToHistoricalEvent(wire);
|
||||
|
||||
domain.EventTimeUtc.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
domain.ReceivedTimeUtc.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the pipe name + shared secret the live <see cref="GalaxyProxyDriver"/> needs
|
||||
/// to connect to a running <c>OtOpcUaGalaxyHost</c> Windows service. Two sources are
|
||||
/// consulted, first match wins:
|
||||
/// <list type="number">
|
||||
/// <item>Explicit env vars (<c>OTOPCUA_GALAXY_PIPE</c>, <c>OTOPCUA_GALAXY_SECRET</c>) — lets CI / benchwork override.</item>
|
||||
/// <item>The service's per-process <c>Environment</c> registry values under
|
||||
/// <c>HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost</c> — what
|
||||
/// <c>Install-Services.ps1</c> writes at install time. Requires the test to run as a
|
||||
/// principal with read access to that registry key (typically Administrators).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Explicitly NOT baked-in-to-source: the shared secret is rotated per install (the
|
||||
/// installer generates 32 random bytes and stores the base64 string). A hard-coded secret
|
||||
/// in tests would diverge from production the moment someone re-installed the service.
|
||||
/// </remarks>
|
||||
public sealed record LiveStackConfig(string PipeName, string SharedSecret, string? Source)
|
||||
{
|
||||
public const string EnvPipeName = "OTOPCUA_GALAXY_PIPE";
|
||||
public const string EnvSharedSecret = "OTOPCUA_GALAXY_SECRET";
|
||||
public const string ServiceRegistryKey =
|
||||
@"SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost";
|
||||
public const string DefaultPipeName = "OtOpcUaGalaxy";
|
||||
|
||||
public static LiveStackConfig? Resolve()
|
||||
{
|
||||
var envPipe = Environment.GetEnvironmentVariable(EnvPipeName);
|
||||
var envSecret = Environment.GetEnvironmentVariable(EnvSharedSecret);
|
||||
if (!string.IsNullOrWhiteSpace(envPipe) && !string.IsNullOrWhiteSpace(envSecret))
|
||||
return new LiveStackConfig(envPipe, envSecret, "env vars");
|
||||
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return null;
|
||||
|
||||
return FromServiceRegistry();
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static LiveStackConfig? FromServiceRegistry()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(ServiceRegistryKey);
|
||||
if (key is null) return null;
|
||||
var env = key.GetValue("Environment") as string[];
|
||||
if (env is null || env.Length == 0) return null;
|
||||
|
||||
string? pipe = null, secret = null;
|
||||
foreach (var line in env)
|
||||
{
|
||||
var eq = line.IndexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
var name = line[..eq];
|
||||
var value = line[(eq + 1)..];
|
||||
if (name.Equals(EnvPipeName, StringComparison.OrdinalIgnoreCase)) pipe = value;
|
||||
else if (name.Equals(EnvSharedSecret, StringComparison.OrdinalIgnoreCase)) secret = value;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secret)) return null;
|
||||
return new LiveStackConfig(pipe ?? DefaultPipeName, secret, "service registry");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Access denied / key missing / malformed — caller gets null and surfaces a Skip.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
|
||||
|
||||
/// <summary>
|
||||
/// Connects a single <see cref="GalaxyProxyDriver"/> to the already-running
|
||||
/// <c>OtOpcUaGalaxyHost</c> Windows service for the lifetime of a test class. Uses
|
||||
/// <see cref="AvevaPrerequisites"/> to decide whether to proceed; on failure,
|
||||
/// <see cref="SkipReason"/> is populated and each test calls <see cref="SkipIfUnavailable"/>
|
||||
/// to translate that into <c>Assert.Skip</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Does NOT spawn the Host process.</b> Production deploys <c>OtOpcUaGalaxyHost</c>
|
||||
/// as a standalone Windows service — spawning a second instance from a test would
|
||||
/// bypass the COM-apartment + service-account setup and fail differently than
|
||||
/// production (see <c>project_galaxy_host_service.md</c> memory).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Shared-secret handling</b>: read from <see cref="LiveStackConfig"/> — env vars
|
||||
/// first, then the service's registry-stored <c>Environment</c> values. Requires
|
||||
/// the test process to have read access to
|
||||
/// <c>HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost</c>; on a dev box
|
||||
/// that typically means running the test host elevated, or exporting
|
||||
/// <c>OTOPCUA_GALAXY_SECRET</c> out-of-band.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LiveStackFixture : IAsyncLifetime
|
||||
{
|
||||
public GalaxyProxyDriver? Driver { get; private set; }
|
||||
|
||||
public string? SkipReason { get; private set; }
|
||||
|
||||
public PrerequisiteReport? PrerequisiteReport { get; private set; }
|
||||
|
||||
public LiveStackConfig? Config { get; private set; }
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
// 0. Elevated-shell short-circuit. The OtOpcUaGalaxyHost pipe ACL allows the configured
|
||||
// SID but explicitly DENIES Administrators (decision #76 — production hardening).
|
||||
// A test process running with a high-integrity token (any elevated shell) carries the
|
||||
// Admins group in its security context, so the deny rule trumps the user's allow and
|
||||
// the pipe connect returns UnauthorizedAccessException — technically correct but
|
||||
// the operationally confusing failure mode that ate most of the PR 37 install
|
||||
// debugging session. Surfacing it explicitly here saves the next operator the same
|
||||
// five-step diagnosis. ParityFixture has the same skip with the same rationale.
|
||||
if (IsElevatedAdministratorOnWindows())
|
||||
{
|
||||
SkipReason =
|
||||
"Test host is running with elevated (Administrators) privileges, but the " +
|
||||
"OtOpcUaGalaxyHost named-pipe ACL explicitly denies Administrators per the IPC " +
|
||||
"security design (decision #76 / PipeAcl.cs). Re-run from a NORMAL (non-admin) " +
|
||||
"PowerShell window — even when your user is already in the pipe's allow list, " +
|
||||
"the elevated token's Admins group membership trumps the allow rule.";
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. AVEVA + OtOpcUa service state — actionable diagnostic if anything is missing.
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
PrerequisiteReport = await AvevaPrerequisites.CheckAllAsync(
|
||||
new AvevaPrerequisites.Options { CheckGalaxyHostPipe = true, CheckHistorian = false },
|
||||
cts.Token);
|
||||
|
||||
if (!PrerequisiteReport.IsLivetestReady)
|
||||
{
|
||||
SkipReason = PrerequisiteReport.SkipReason;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Secret / pipe-name resolution. If the service is running but we can't discover its
|
||||
// env vars from registry (non-elevated test host), a clear message beats a silent
|
||||
// connect-rejected failure 10 seconds later.
|
||||
Config = LiveStackConfig.Resolve();
|
||||
if (Config is null)
|
||||
{
|
||||
SkipReason =
|
||||
$"Cannot resolve shared secret. Set {LiveStackConfig.EnvSharedSecret} (and optionally " +
|
||||
$"{LiveStackConfig.EnvPipeName}) in the environment, or run the test host elevated so it " +
|
||||
$"can read HKLM\\{LiveStackConfig.ServiceRegistryKey}\\Environment.";
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Connect. InitializeAsync does the pipe connect + handshake; a 5-second
|
||||
// ConnectTimeout gives enough headroom for a service that just started.
|
||||
Driver = new GalaxyProxyDriver(new GalaxyProxyOptions
|
||||
{
|
||||
DriverInstanceId = "live-stack-smoke",
|
||||
PipeName = Config.PipeName,
|
||||
SharedSecret = Config.SharedSecret,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(5),
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await Driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SkipReason =
|
||||
$"Connected to named pipe '{Config.PipeName}' but GalaxyProxyDriver.InitializeAsync failed: " +
|
||||
$"{ex.GetType().Name}: {ex.Message}. Common causes: shared secret mismatch (rotated after last install), " +
|
||||
$"service account SID not in pipe ACL (installer sets OTOPCUA_ALLOWED_SID to the service account — " +
|
||||
$"test must run as that user), or Host's backend couldn't connect to ZB.";
|
||||
Driver.Dispose();
|
||||
Driver = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Driver is not null)
|
||||
{
|
||||
try { await Driver.ShutdownAsync(CancellationToken.None); } catch { /* best-effort */ }
|
||||
Driver.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translate <see cref="SkipReason"/> into <c>Assert.Skip</c>. Tests call this at the
|
||||
/// top of every fact so a fixture init failure shows up as a cleanly-skipped test with
|
||||
/// the full prerequisites report, not a cascading NullReferenceException on
|
||||
/// <see cref="Driver"/>.
|
||||
/// </summary>
|
||||
public void SkipIfUnavailable()
|
||||
{
|
||||
if (SkipReason is not null) Assert.Skip(SkipReason);
|
||||
}
|
||||
|
||||
private static bool IsElevatedAdministratorOnWindows()
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return false;
|
||||
return CheckWindowsAdminToken();
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static bool CheckWindowsAdminToken()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Probe shouldn't crash the test; if we can't determine elevation, optimistically
|
||||
// continue and let the actual pipe connect surface its own error.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class LiveStackCollection : ICollectionFixture<LiveStackFixture>
|
||||
{
|
||||
public const string Name = "LiveStack";
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end smoke against the installed <c>OtOpcUaGalaxyHost</c> Windows service.
|
||||
/// Closes LMX follow-up #5 — exercises the full topology: <see cref="GalaxyProxyDriver"/>
|
||||
/// in-process → named-pipe IPC → <c>OtOpcUaGalaxyHost</c> service → <c>MxAccessGalaxyBackend</c> →
|
||||
/// live MXAccess runtime → real Galaxy objects + attributes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Preconditions</b> (all checked by <see cref="LiveStackFixture"/>, surfaced via
|
||||
/// <c>Assert.Skip</c> when missing):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>AVEVA System Platform installed + Platform deployed.</item>
|
||||
/// <item><c>aaBootstrap</c> / <c>aaGR</c> / <c>NmxSvc</c> / <c>MSSQLSERVER</c> running.</item>
|
||||
/// <item>MXAccess COM server registered.</item>
|
||||
/// <item>ZB database exists with at least one deployed gobject.</item>
|
||||
/// <item><c>OtOpcUaGalaxyHost</c> service installed + running (named pipe accepting connections).</item>
|
||||
/// <item>Shared secret discoverable via <c>OTOPCUA_GALAXY_SECRET</c> env var or the
|
||||
/// service's registry Environment values (test host typically needs to be elevated
|
||||
/// to read the latter).</item>
|
||||
/// <item>Test process runs as the account listed in the service's pipe ACL
|
||||
/// (<c>OTOPCUA_ALLOWED_SID</c>, typically the service account per decision #76).</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Tests here are deliberately read-only. Writes against live Galaxy attributes are a
|
||||
/// separate concern — they need a test-only UDA or an agreed scratch tag so they can't
|
||||
/// accidentally mutate a process-critical value. Adding a write test is a follow-up
|
||||
/// PR that reuses this fixture.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "LiveGalaxy")]
|
||||
[Collection(LiveStackCollection.Name)]
|
||||
public sealed class LiveStackSmokeTests(LiveStackFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public void Fixture_initialized_successfully()
|
||||
{
|
||||
fixture.SkipIfUnavailable();
|
||||
// If the fixture init succeeded, Driver is non-null and InitializeAsync completed.
|
||||
// This is the cheapest possible assertion that the IPC handshake worked end-to-end;
|
||||
// every other test in this class depends on it.
|
||||
fixture.Driver.ShouldNotBeNull();
|
||||
fixture.Config.ShouldNotBeNull();
|
||||
fixture.PrerequisiteReport.ShouldNotBeNull();
|
||||
fixture.PrerequisiteReport!.IsLivetestReady.ShouldBeTrue(fixture.PrerequisiteReport.SkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_reports_Healthy_after_IPC_handshake()
|
||||
{
|
||||
fixture.SkipIfUnavailable();
|
||||
var health = fixture.Driver!.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy,
|
||||
$"Expected Healthy after successful IPC connect; Reason={health.LastError}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_returns_at_least_one_variable_from_live_galaxy()
|
||||
{
|
||||
fixture.SkipIfUnavailable();
|
||||
var builder = new CapturingAddressSpaceBuilder();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
await fixture.Driver!.DiscoverAsync(builder, cts.Token);
|
||||
|
||||
builder.Variables.Count.ShouldBeGreaterThan(0,
|
||||
"Live Galaxy has > 0 deployed objects per the prereq check — at least one variable must be discovered. " +
|
||||
"Zero usually means the Host couldn't read ZB (check OTOPCUA_GALAXY_ZB_CONN in the service Environment).");
|
||||
|
||||
// Every discovered attribute must carry a non-empty FullName so the OPC UA server can
|
||||
// route reads/writes back. Regression guard — PR 19 normalized this across drivers.
|
||||
builder.Variables.ShouldAllBe(v => !string.IsNullOrEmpty(v.AttributeInfo.FullName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHostStatuses_reports_at_least_one_platform()
|
||||
{
|
||||
fixture.SkipIfUnavailable();
|
||||
var statuses = fixture.Driver!.GetHostStatuses();
|
||||
statuses.Count.ShouldBeGreaterThan(0,
|
||||
"Live Galaxy must report at least one Platform/AppEngine host via IHostConnectivityProbe. " +
|
||||
"Zero means the Host's probe loop hasn't completed its first tick or the Platform isn't deployed locally.");
|
||||
|
||||
// Host names are driver-opaque to the Core but non-empty by contract.
|
||||
statuses.ShouldAllBe(h => !string.IsNullOrEmpty(h.HostName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Can_read_a_discovered_variable_from_live_galaxy()
|
||||
{
|
||||
fixture.SkipIfUnavailable();
|
||||
var builder = new CapturingAddressSpaceBuilder();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
await fixture.Driver!.DiscoverAsync(builder, cts.Token);
|
||||
builder.Variables.Count.ShouldBeGreaterThan(0);
|
||||
|
||||
// Pick the first discovered variable. Read-only smoke — we don't assert on Value,
|
||||
// only that a ReadAsync round-trip through Proxy → Host pipe → MXAccess → back
|
||||
// returns a snapshot with a non-BadInternalError status. Galaxy attributes default to
|
||||
// Uncertain quality until the Engine's first scan publishes them, which is fine here.
|
||||
var full = builder.Variables[0].AttributeInfo.FullName;
|
||||
var snapshots = await fixture.Driver!.ReadAsync([full], cts.Token);
|
||||
|
||||
snapshots.Count.ShouldBe(1);
|
||||
var snap = snapshots[0];
|
||||
snap.StatusCode.ShouldNotBe(0x80020000u,
|
||||
$"Read returned BadInternalError for {full} — the Host couldn't fulfil the request. " +
|
||||
$"Investigate: the Host service's logs at {System.Environment.GetFolderPath(System.Environment.SpecialFolder.CommonApplicationData)}\\OtOpcUa\\Galaxy\\logs.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_then_read_roundtrips_a_writable_Boolean_attribute_on_TestMachine_001()
|
||||
{
|
||||
// PR 40 — finishes LMX #5. Targets DelmiaReceiver_001.TestAttribute, the writable
|
||||
// Boolean attribute on the TestMachine_001 hierarchy that the dev Galaxy was deployed
|
||||
// with for exactly this kind of integration testing. We invert the current value and
|
||||
// assert the new value comes back, then restore the original so the test is effectively
|
||||
// idempotent (Galaxy holds the value across runs since it's a deployed UDA).
|
||||
fixture.SkipIfUnavailable();
|
||||
const string fullRef = "DelmiaReceiver_001.TestAttribute";
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Read current value first — gives the cleanup path the right baseline. Galaxy may
|
||||
// return Uncertain quality until the Engine has scanned the attribute at least once;
|
||||
// we don't read into a strongly-typed bool until Status is Good.
|
||||
var before = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
|
||||
before.StatusCode.ShouldNotBe(0x80020000u, $"baseline read failed for {fullRef}: {before.Value}");
|
||||
var originalBool = Convert.ToBoolean(before.Value ?? false);
|
||||
var inverted = !originalBool;
|
||||
|
||||
try
|
||||
{
|
||||
// Write the inverted value via IWritable.
|
||||
var writeResults = await fixture.Driver!.WriteAsync(
|
||||
[new(fullRef, inverted)], cts.Token);
|
||||
writeResults.Count.ShouldBe(1);
|
||||
writeResults[0].StatusCode.ShouldBe(0u,
|
||||
$"WriteAsync returned status 0x{writeResults[0].StatusCode:X8} for {fullRef} — " +
|
||||
$"check the Host service log at %ProgramData%\\OtOpcUa\\Galaxy\\.");
|
||||
|
||||
// The Engine's scan + acknowledgement is async — read in a short loop with a 5s
|
||||
// budget. Galaxy's attribute roundtrip on a dev box is typically sub-second but
|
||||
// we give headroom for first-scan after a service restart.
|
||||
DataValueSnapshot after = default!;
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
after = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
|
||||
if (after.StatusCode == 0u && Convert.ToBoolean(after.Value ?? false) == inverted) break;
|
||||
await Task.Delay(200, cts.Token);
|
||||
}
|
||||
after.StatusCode.ShouldBe(0u, "post-write read failed");
|
||||
Convert.ToBoolean(after.Value ?? false).ShouldBe(inverted,
|
||||
$"Wrote {inverted} but Galaxy returned {after.Value} after the scan window.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore — best-effort. If this throws the test still reports its primary result;
|
||||
// we just leave a flipped TestAttribute on the dev box (benign, name says it all).
|
||||
try { await fixture.Driver!.WriteAsync([new(fullRef, originalBool)], cts.Token); }
|
||||
catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_fires_OnDataChange_with_initial_value_then_again_after_a_write()
|
||||
{
|
||||
// Subscribe + write is the canonical "is the data path actually live" test for
|
||||
// an OPC UA driver. We subscribe to the same Boolean attribute, expect an initial-
|
||||
// value callback within a couple of seconds (per ISubscribable's contract — the
|
||||
// driver MAY fire OnDataChange immediately with the current value), then write a
|
||||
// distinct value and expect a second callback carrying the new value.
|
||||
fixture.SkipIfUnavailable();
|
||||
const string fullRef = "DelmiaReceiver_001.TestAttribute";
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Capture every OnDataChange notification for this fullRef onto a thread-safe queue
|
||||
// we can poll from the test thread. Galaxy's MXAccess advisory fires on its own
|
||||
// thread; we don't want to block it.
|
||||
var notifications = new System.Collections.Concurrent.ConcurrentQueue<DataValueSnapshot>();
|
||||
void Handler(object? sender, DataChangeEventArgs e)
|
||||
{
|
||||
if (string.Equals(e.FullReference, fullRef, StringComparison.OrdinalIgnoreCase))
|
||||
notifications.Enqueue(e.Snapshot);
|
||||
}
|
||||
fixture.Driver!.OnDataChange += Handler;
|
||||
|
||||
// Read current value so we know which value to write to force a transition.
|
||||
var before = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
|
||||
var originalBool = Convert.ToBoolean(before.Value ?? false);
|
||||
var toWrite = !originalBool;
|
||||
|
||||
ISubscriptionHandle? handle = null;
|
||||
try
|
||||
{
|
||||
handle = await fixture.Driver!.SubscribeAsync(
|
||||
[fullRef], TimeSpan.FromMilliseconds(250), cts.Token);
|
||||
|
||||
// Wait for initial-value notification — typical < 1s on a hot Galaxy, give 5s.
|
||||
await WaitForAsync(() => notifications.Count >= 1, TimeSpan.FromSeconds(5), cts.Token);
|
||||
notifications.Count.ShouldBeGreaterThanOrEqualTo(1,
|
||||
$"No initial-value OnDataChange for {fullRef} within 5s. " +
|
||||
$"Either MXAccess subscription failed silently or the Engine hasn't scanned yet.");
|
||||
|
||||
// Drain the initial-value queue before writing so we count post-write deltas only.
|
||||
var initialCount = notifications.Count;
|
||||
|
||||
// Write the toggled value. Engine scan + advisory fires the second callback.
|
||||
var w = await fixture.Driver!.WriteAsync([new(fullRef, toWrite)], cts.Token);
|
||||
w[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
await WaitForAsync(() => notifications.Count > initialCount, TimeSpan.FromSeconds(8), cts.Token);
|
||||
notifications.Count.ShouldBeGreaterThan(initialCount,
|
||||
$"OnDataChange did not fire after writing {toWrite} to {fullRef} within 8s.");
|
||||
|
||||
// Find the post-write notification carrying the toggled value (initial value may
|
||||
// appear multiple times before the write commits — search the tail).
|
||||
var postWrite = notifications.ToArray().Reverse()
|
||||
.FirstOrDefault(n => n.StatusCode == 0u && Convert.ToBoolean(n.Value ?? false) == toWrite);
|
||||
postWrite.ShouldNotBe(default,
|
||||
$"No OnDataChange carrying the toggled value {toWrite} appeared in the queue: " +
|
||||
string.Join(",", notifications.Select(n => $"{n.Value}@{n.StatusCode:X8}")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
fixture.Driver!.OnDataChange -= Handler;
|
||||
if (handle is not null)
|
||||
{
|
||||
try { await fixture.Driver!.UnsubscribeAsync(handle, cts.Token); } catch { /* swallow */ }
|
||||
}
|
||||
// Restore baseline.
|
||||
try { await fixture.Driver!.WriteAsync([new(fullRef, originalBool)], cts.Token); } catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> predicate, TimeSpan budget, CancellationToken ct)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + budget;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (predicate()) return;
|
||||
await Task.Delay(100, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="IAddressSpaceBuilder"/> implementation that captures every
|
||||
/// Variable() call into a flat list so tests can inspect what discovery produced
|
||||
/// without running the full OPC UA node-manager stack.
|
||||
/// </summary>
|
||||
private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, DriverAttributeInfo AttributeInfo)> Variables { get; } = [];
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add((browseName, attributeInfo));
|
||||
return new NoopHandle(attributeInfo.FullName);
|
||||
}
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class NoopHandle(string fullReference) : IVariableHandle
|
||||
{
|
||||
public string FullReference { get; } = fullReference;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NoopSink();
|
||||
private sealed class NoopSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for live-AVEVA test fixtures. Runs every relevant probe and returns a
|
||||
/// <see cref="PrerequisiteReport"/> whose <c>SkipReason</c> feeds <c>Assert.Skip</c> when
|
||||
/// the environment isn't set up. Non-Windows hosts get a single aggregated Skip row per
|
||||
/// category instead of a flood of individual skips.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Call shape</b>:</para>
|
||||
/// <code>
|
||||
/// var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
/// if (report.SkipReason is not null) Assert.Skip(report.SkipReason);
|
||||
/// </code>
|
||||
/// <para><b>Categories in rough order of 'would I want to know first?'</b>:</para>
|
||||
/// <list type="number">
|
||||
/// <item>Environment — process bitness, OS platform, RPCSS up.</item>
|
||||
/// <item>AvevaInstall — Framework registry, install paths, no pending reboot.</item>
|
||||
/// <item>AvevaCoreService — aaBootstrap / aaGR / NmxSvc running.</item>
|
||||
/// <item>MxAccessCom — LMXProxy.LMXProxyServer ProgID → CLSID → file-on-disk.</item>
|
||||
/// <item>GalaxyRepository — SQL reachable, ZB exists, deployed-object count.</item>
|
||||
/// <item>OtOpcUaService — our two Windows services + GLAuth.</item>
|
||||
/// <item>AvevaSoftService — aaLogger etc., warn only.</item>
|
||||
/// <item>AvevaHistorian — aahClientAccessPoint etc., optional.</item>
|
||||
/// </list>
|
||||
/// <para><b>What's NOT checked here</b>: end-to-end subscribe / read / write against a real
|
||||
/// Galaxy tag. That's the job of the live-smoke tests this helper gates — the helper just
|
||||
/// tells them whether running is worthwhile.</para>
|
||||
/// </remarks>
|
||||
public static class AvevaPrerequisites
|
||||
{
|
||||
// -------- Individual service lists (kept as data so tests can inspect / override) --------
|
||||
|
||||
/// <summary>Services whose absence means live-Galaxy tests can't run at all.</summary>
|
||||
internal static readonly (string Name, string Purpose)[] CoreServices =
|
||||
[
|
||||
("aaBootstrap", "master service that starts the Platform process + brokers aa* communication"),
|
||||
("aaGR", "Galaxy Repository host — mediates IDE / runtime access to ZB"),
|
||||
("NmxSvc", "Network Message Exchange — MXAccess + Bootstrap transport"),
|
||||
("MSSQLSERVER", "SQL Server instance that hosts the ZB database"),
|
||||
];
|
||||
|
||||
/// <summary>Warn-but-don't-fail AVEVA services.</summary>
|
||||
internal static readonly (string Name, string Purpose)[] SoftServices =
|
||||
[
|
||||
("aaLogger", "ArchestrA Logger — diagnostic log receiver; stack runs without it but error visibility suffers"),
|
||||
("aaUserValidator", "OS user/group auth for ArchestrA security; only required when Galaxy security mode isn't 'Open'"),
|
||||
("aaGlobalDataCacheMonitorSvr", "cross-platform global data cache; single-node dev boxes run fine without it"),
|
||||
];
|
||||
|
||||
/// <summary>Optional AVEVA Historian services — only required for HistoryRead IPC paths.</summary>
|
||||
internal static readonly (string Name, string Purpose)[] HistorianServices =
|
||||
[
|
||||
("aahClientAccessPoint", "AVEVA Historian Client Access Point — HistoryRead IPC endpoint"),
|
||||
("aahGateway", "AVEVA Historian Gateway"),
|
||||
];
|
||||
|
||||
/// <summary>OtOpcUa-stack Windows services + third-party deps we manage.</summary>
|
||||
internal static readonly (string Name, string Purpose, bool HardRequired)[] OtOpcUaServices =
|
||||
[
|
||||
("OtOpcUaGalaxyHost", "Galaxy.Host out-of-process service (net48 x86, STA + MXAccess)", true),
|
||||
("OtOpcUa", "Main OPC UA server service (hosts Proxy + DriverHost + Admin-facing DB publisher)", false),
|
||||
("GLAuth", "LDAP server (dev only) — glauth.exe on localhost:3893", false),
|
||||
];
|
||||
|
||||
// -------- Orchestrator --------
|
||||
|
||||
public static async Task<PrerequisiteReport> CheckAllAsync(
|
||||
Options? options = null, CancellationToken ct = default)
|
||||
{
|
||||
options ??= new Options();
|
||||
var checks = new List<PrerequisiteCheck>();
|
||||
|
||||
// Environment
|
||||
checks.Add(MxAccessComProbe.CheckProcessBitness());
|
||||
|
||||
// AvevaInstall — registry + files
|
||||
checks.Add(RegistryProbe.CheckFrameworkInstalled());
|
||||
checks.Add(RegistryProbe.CheckPlatformDeployed());
|
||||
checks.Add(RegistryProbe.CheckRebootPending());
|
||||
|
||||
// AvevaCoreService
|
||||
foreach (var (name, purpose) in CoreServices)
|
||||
checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaCoreService, hardRequired: true, whatItDoes: purpose));
|
||||
|
||||
// MxAccessCom
|
||||
checks.Add(MxAccessComProbe.Check());
|
||||
|
||||
// GalaxyRepository
|
||||
checks.Add(await SqlProbe.CheckZbDatabaseAsync(options.SqlConnectionString, ct));
|
||||
// Deployed-object count only makes sense if the DB check passed.
|
||||
if (checks[checks.Count - 1].Status == PrerequisiteStatus.Pass)
|
||||
checks.Add(await SqlProbe.CheckDeployedObjectCountAsync(options.SqlConnectionString, ct));
|
||||
|
||||
// OtOpcUaService
|
||||
foreach (var (name, purpose, hard) in OtOpcUaServices)
|
||||
checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.OtOpcUaService, hardRequired: hard, whatItDoes: purpose));
|
||||
if (options.CheckGalaxyHostPipe)
|
||||
checks.Add(await NamedPipeProbe.CheckGalaxyHostPipeAsync(options.GalaxyHostPipeName, ct));
|
||||
|
||||
// AvevaSoftService
|
||||
foreach (var (name, purpose) in SoftServices)
|
||||
checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaSoftService, hardRequired: false, whatItDoes: purpose));
|
||||
|
||||
// AvevaHistorian
|
||||
if (options.CheckHistorian)
|
||||
{
|
||||
foreach (var (name, purpose) in HistorianServices)
|
||||
checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaHistorian, hardRequired: false, whatItDoes: purpose));
|
||||
}
|
||||
|
||||
return new PrerequisiteReport(checks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Narrower check for tests that only need the Galaxy Repository (SQL) path — don't
|
||||
/// pay the cost of probing every aa* service when the test only reads gobject rows.
|
||||
/// </summary>
|
||||
public static async Task<PrerequisiteReport> CheckRepositoryOnlyAsync(
|
||||
string? sqlConnectionString = null, CancellationToken ct = default)
|
||||
{
|
||||
var checks = new List<PrerequisiteCheck>
|
||||
{
|
||||
await SqlProbe.CheckZbDatabaseAsync(sqlConnectionString, ct),
|
||||
};
|
||||
if (checks[0].Status == PrerequisiteStatus.Pass)
|
||||
checks.Add(await SqlProbe.CheckDeployedObjectCountAsync(sqlConnectionString, ct));
|
||||
return new PrerequisiteReport(checks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Narrower check for the named-pipe endpoint — tests that drive the full Proxy
|
||||
/// against a live Galaxy.Host service don't need the SQL or AVEVA-internal probes
|
||||
/// (the Host does that work internally; we just need the pipe to accept).
|
||||
/// </summary>
|
||||
public static async Task<PrerequisiteReport> CheckGalaxyHostPipeOnlyAsync(
|
||||
string? pipeName = null, CancellationToken ct = default)
|
||||
{
|
||||
var checks = new List<PrerequisiteCheck>
|
||||
{
|
||||
await NamedPipeProbe.CheckGalaxyHostPipeAsync(pipeName, ct),
|
||||
};
|
||||
return new PrerequisiteReport(checks);
|
||||
}
|
||||
|
||||
/// <summary>Knobs for <see cref="CheckAllAsync"/>.</summary>
|
||||
public sealed class Options
|
||||
{
|
||||
/// <summary>SQL Server connection string — defaults to Windows-auth <c>localhost\ZB</c>.</summary>
|
||||
public string? SqlConnectionString { get; init; }
|
||||
|
||||
/// <summary>Named-pipe endpoint for OtOpcUaGalaxyHost — defaults to <c>OtOpcUaGalaxy</c>.</summary>
|
||||
public string? GalaxyHostPipeName { get; init; }
|
||||
|
||||
/// <summary>Include the named-pipe probe. Off by default — it's a seconds-long TCP-like probe and some tests don't need it.</summary>
|
||||
public bool CheckGalaxyHostPipe { get; init; } = true;
|
||||
|
||||
/// <summary>Include Historian service probes. Off by default — Historian is optional.</summary>
|
||||
public bool CheckHistorian { get; init; } = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#if NET48
|
||||
// Polyfills for C# 9+ language features that the helper uses but that net48 BCL doesn't
|
||||
// provide. Keeps the sources single-target-free at the language level — the same .cs files
|
||||
// build on both frameworks without preprocessor guards in the callsites.
|
||||
|
||||
namespace System.Runtime.CompilerServices
|
||||
{
|
||||
/// <summary>Required by C# 9 <c>init</c>-only setters and <c>record</c> types.</summary>
|
||||
internal static class IsExternalInit { }
|
||||
}
|
||||
|
||||
namespace System.Runtime.Versioning
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimal shim for the .NET 5+ <c>SupportedOSPlatformAttribute</c>. Pure marker for the
|
||||
/// compiler on net10; on net48 we still want the attribute to exist so the same
|
||||
/// <c>[SupportedOSPlatform("windows")]</c> source compiles. The attribute is internal
|
||||
/// and attribute-targets-everything to minimize surface.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = true)]
|
||||
internal sealed class SupportedOSPlatformAttribute(string platformName) : Attribute
|
||||
{
|
||||
public string PlatformName { get; } = platformName;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
/// <summary>One prerequisite probe's outcome. <see cref="AvevaPrerequisites"/> returns many of these.</summary>
|
||||
/// <param name="Name">Short diagnostic id — e.g. <c>service:aaBootstrap</c>, <c>sql:ZB</c>, <c>registry:ArchestrA.Framework</c>.</param>
|
||||
/// <param name="Category">Which subsystem the probe belongs to — lets callers filter (e.g. "Historian warns don't gate the core Galaxy smoke").</param>
|
||||
/// <param name="Status">Outcome.</param>
|
||||
/// <param name="Detail">One-line specific message an operator can act on — <c>"aaGR not installed — install the Galaxy Repository role from the System Platform setup"</c> beats <c>"failed"</c>.</param>
|
||||
public sealed record PrerequisiteCheck(
|
||||
string Name,
|
||||
PrerequisiteCategory Category,
|
||||
PrerequisiteStatus Status,
|
||||
string Detail);
|
||||
|
||||
public enum PrerequisiteStatus
|
||||
{
|
||||
/// <summary>Prerequisite is met; no action needed.</summary>
|
||||
Pass,
|
||||
/// <summary>Soft dependency missing — stack still runs but some feature (e.g. logging) is degraded.</summary>
|
||||
Warn,
|
||||
/// <summary>Hard dependency missing — live tests can't proceed; <see cref="PrerequisiteReport.SkipReason"/> surfaces this.</summary>
|
||||
Fail,
|
||||
/// <summary>Probe wasn't applicable in this environment (e.g. non-Windows host, Historian not installed).</summary>
|
||||
Skip,
|
||||
}
|
||||
|
||||
public enum PrerequisiteCategory
|
||||
{
|
||||
/// <summary>Platform sanity — process bitness, OS platform, DCOM/RPCSS.</summary>
|
||||
Environment,
|
||||
/// <summary>Hard-required AVEVA Windows services (aaBootstrap, aaGR, NmxSvc).</summary>
|
||||
AvevaCoreService,
|
||||
/// <summary>Soft-required AVEVA Windows services (aaLogger, aaUserValidator) — warn only.</summary>
|
||||
AvevaSoftService,
|
||||
/// <summary>ArchestrA Framework install markers (registry + files).</summary>
|
||||
AvevaInstall,
|
||||
/// <summary>MXAccess COM server registration + file on disk.</summary>
|
||||
MxAccessCom,
|
||||
/// <summary>SQL Server reachability + ZB database presence + deployed-object count.</summary>
|
||||
GalaxyRepository,
|
||||
/// <summary>Historian services (optional — only required for HistoryRead IPC paths).</summary>
|
||||
AvevaHistorian,
|
||||
/// <summary>OtOpcUa-side services (OtOpcUa, OtOpcUaGalaxyHost) + third-party deps (GLAuth).</summary>
|
||||
OtOpcUaService,
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated result of an <see cref="AvevaPrerequisites.CheckAll"/> run. Test fixtures
|
||||
/// typically call <see cref="SkipReason"/> to produce the argument for xUnit's
|
||||
/// <c>Assert.Skip</c> when any hard dependency failed.
|
||||
/// </summary>
|
||||
public sealed class PrerequisiteReport
|
||||
{
|
||||
public IReadOnlyList<PrerequisiteCheck> Checks { get; }
|
||||
|
||||
public PrerequisiteReport(IEnumerable<PrerequisiteCheck> checks)
|
||||
{
|
||||
Checks = [.. checks];
|
||||
}
|
||||
|
||||
/// <summary>True when every probe is Pass / Warn / Skip — no Fail entries.</summary>
|
||||
public bool IsLivetestReady => !Checks.Any(c => c.Status == PrerequisiteStatus.Fail);
|
||||
|
||||
/// <summary>
|
||||
/// True when only the AVEVA-side probes pass — ignores failures in the
|
||||
/// <see cref="PrerequisiteCategory.OtOpcUaService"/> category. Lets a live-test gate
|
||||
/// say "AVEVA is ready even if the v2 services aren't installed yet" without
|
||||
/// conflating the two. Useful for tests that exercise Galaxy directly (e.g.
|
||||
/// <see cref="GalaxyRepositoryLiveSmokeTests"/>) rather than through our stack.
|
||||
/// </summary>
|
||||
public bool IsAvevaSideReady =>
|
||||
!Checks.Any(c => c.Status == PrerequisiteStatus.Fail && c.Category != PrerequisiteCategory.OtOpcUaService);
|
||||
|
||||
/// <summary>
|
||||
/// Multi-line message for <c>Assert.Skip</c> when a hard dependency isn't met. Returns
|
||||
/// null when <see cref="IsLivetestReady"/> is true.
|
||||
/// </summary>
|
||||
public string? SkipReason
|
||||
{
|
||||
get
|
||||
{
|
||||
var fails = Checks.Where(c => c.Status == PrerequisiteStatus.Fail).ToList();
|
||||
if (fails.Count == 0) return null;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Live-AVEVA prerequisites not met ({fails.Count} failed):");
|
||||
foreach (var f in fails)
|
||||
sb.AppendLine($" • [{f.Category}] {f.Name} — {f.Detail}");
|
||||
sb.Append("Run `Get-Service aa*` / `sqlcmd -S localhost -d ZB -E -Q \"SELECT 1\"` to triage.");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary of warnings — caller decides whether to log or ignore. Useful
|
||||
/// when a live test does pass but an operator should know their environment is degraded.
|
||||
/// </summary>
|
||||
public string? Warnings
|
||||
{
|
||||
get
|
||||
{
|
||||
var warns = Checks.Where(c => c.Status == PrerequisiteStatus.Warn).ToList();
|
||||
if (warns.Count == 0) return null;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"AVEVA prerequisites with warnings ({warns.Count}):");
|
||||
foreach (var w in warns)
|
||||
sb.AppendLine($" • [{w.Category}] {w.Name} — {w.Detail}");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throw <see cref="InvalidOperationException"/> if any <paramref name="categories"/>
|
||||
/// contain a Fail — useful when a specific test needs, say, Galaxy Repository but doesn't
|
||||
/// care about Historian. Call before <c>Assert.Skip</c> if you want to be strict.
|
||||
/// </summary>
|
||||
public void RequireCategories(params PrerequisiteCategory[] categories)
|
||||
{
|
||||
var set = categories.ToHashSet();
|
||||
var fails = Checks.Where(c => c.Status == PrerequisiteStatus.Fail && set.Contains(c.Category)).ToList();
|
||||
if (fails.Count == 0) return;
|
||||
|
||||
var detail = string.Join("; ", fails.Select(f => $"{f.Name}: {f.Detail}"));
|
||||
throw new InvalidOperationException($"Required prerequisite categories failed: {detail}");
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"PrerequisiteReport: {Checks.Count} checks");
|
||||
foreach (var c in Checks)
|
||||
sb.AppendLine($" [{c.Status,-4}] {c.Category}/{c.Name}: {c.Detail}");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Confirms MXAccess COM server registration by resolving the
|
||||
/// <c>LMXProxy.LMXProxyServer</c> ProgID to its CLSID, then checking that the CLSID's
|
||||
/// 32-bit <c>InprocServer32</c> entry points at a file that exists on disk.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A common failure mode on partial installs: ProgID is registered but the CLSID
|
||||
/// InprocServer32 DLL is missing (previous install uninstalled but registry orphan remains).
|
||||
/// This probe surfaces that case with an actionable message instead of the
|
||||
/// <c>0x80040154 REGDB_E_CLASSNOTREG</c> you'd see from a late COM activation failure.
|
||||
/// </remarks>
|
||||
public static class MxAccessComProbe
|
||||
{
|
||||
public const string ProgId = "LMXProxy.LMXProxyServer";
|
||||
public const string VersionedProgId = "LMXProxy.LMXProxyServer.1";
|
||||
|
||||
public static PrerequisiteCheck Check()
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Skip, "COM registration probes only run on Windows.");
|
||||
}
|
||||
return CheckWindows();
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static PrerequisiteCheck CheckWindows()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (clsid, dll) = RegistryProbe.ResolveProgIdToInproc(ProgId);
|
||||
if (clsid is null)
|
||||
{
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"ProgID {ProgId} not registered — MXAccess COM server isn't installed. " +
|
||||
$"Install System Platform's MXAccess component and re-run.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dll))
|
||||
{
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"ProgID {ProgId} → CLSID {clsid} but InprocServer32 is empty. " +
|
||||
$"Registry is orphaned; re-register with: regsvr32 /s LmxProxy.dll (from an elevated cmd in the Framework bin dir).");
|
||||
}
|
||||
|
||||
// Resolve the recorded path — sometimes registered as a bare filename that the COM
|
||||
// runtime resolves via the current process's DLL-search path. Accept either an
|
||||
// absolute path that exists, or a bare filename whose resolution we can't verify
|
||||
// without loading it (treat as Pass-with-note).
|
||||
if (Path.IsPathRooted(dll))
|
||||
{
|
||||
if (!File.Exists(dll))
|
||||
{
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"ProgID {ProgId} → CLSID {clsid} → InprocServer32 {dll}, but the file is missing. " +
|
||||
$"Re-install the Framework or restore from backup.");
|
||||
}
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Pass,
|
||||
$"ProgID {ProgId} → {dll} (file exists).");
|
||||
}
|
||||
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Pass,
|
||||
$"ProgID {ProgId} → {dll} (bare filename — relies on PATH resolution at COM activation time).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Probe failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warn when running as a 64-bit process — MXAccess COM activation will fail with
|
||||
/// <c>0x80040154</c> regardless of registration state. The production drivers run net48
|
||||
/// x86; xunit hosts run 64-bit by default so this often surfaces first.
|
||||
/// </summary>
|
||||
public static PrerequisiteCheck CheckProcessBitness()
|
||||
{
|
||||
if (Environment.Is64BitProcess)
|
||||
{
|
||||
return new PrerequisiteCheck("env:ProcessBitness", PrerequisiteCategory.Environment,
|
||||
PrerequisiteStatus.Warn,
|
||||
"Test host is 64-bit. Direct MXAccess COM activation would fail with REGDB_E_CLASSNOTREG (0x80040154); " +
|
||||
"the production driver workaround is to run Galaxy.Host as a 32-bit process. Tests that only " +
|
||||
"talk to the Host service over the named pipe aren't affected.");
|
||||
}
|
||||
return new PrerequisiteCheck("env:ProcessBitness", PrerequisiteCategory.Environment,
|
||||
PrerequisiteStatus.Pass, "Test host is 32-bit.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.IO.Pipes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the <c>OtOpcUaGalaxyHost</c> named-pipe endpoint is accepting connections —
|
||||
/// the handshake the Proxy performs at boot. A clean pipe connect without sending any
|
||||
/// framed message proves the Host service is listening; we disconnect immediately so we
|
||||
/// don't consume a session slot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Default pipe name matches the installer script's <c>OTOPCUA_GALAXY_PIPE</c> default.
|
||||
/// Override when the Host service was installed with a non-default name (custom deployments).
|
||||
/// </remarks>
|
||||
public static class NamedPipeProbe
|
||||
{
|
||||
public const string DefaultGalaxyHostPipeName = "OtOpcUaGalaxy";
|
||||
|
||||
public static async Task<PrerequisiteCheck> CheckGalaxyHostPipeAsync(
|
||||
string? pipeName = null, CancellationToken ct = default)
|
||||
{
|
||||
pipeName ??= DefaultGalaxyHostPipeName;
|
||||
try
|
||||
{
|
||||
using var client = new NamedPipeClientStream(
|
||||
serverName: ".",
|
||||
pipeName: pipeName,
|
||||
direction: PipeDirection.InOut,
|
||||
options: PipeOptions.Asynchronous);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(2));
|
||||
await client.ConnectAsync(cts.Token);
|
||||
|
||||
return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService,
|
||||
PrerequisiteStatus.Pass,
|
||||
$@"Pipe \\.\pipe\{pipeName} accepted a connection — OtOpcUaGalaxyHost is listening.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService,
|
||||
PrerequisiteStatus.Fail,
|
||||
$@"Pipe \\.\pipe\{pipeName} not connectable within 2s — OtOpcUaGalaxyHost service isn't running. " +
|
||||
"Start with: sc.exe start OtOpcUaGalaxyHost");
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService,
|
||||
PrerequisiteStatus.Fail,
|
||||
$@"Pipe \\.\pipe\{pipeName} connect timed out — service may be starting or stuck. " +
|
||||
"Check: sc.exe query OtOpcUaGalaxyHost");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService,
|
||||
PrerequisiteStatus.Fail,
|
||||
$@"Pipe \\.\pipe\{pipeName} connect failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Reads HKLM registry keys to confirm ArchestrA Framework / System Platform install
|
||||
/// markers. Matches the registered paths documented in
|
||||
/// <c>docs/v2/implementation/</c> — System Platform is 32-bit so keys live under
|
||||
/// <c>HKLM\SOFTWARE\WOW6432Node\ArchestrA\...</c>.
|
||||
/// </summary>
|
||||
public static class RegistryProbe
|
||||
{
|
||||
// Canonical install roots per the research on our dev box (System Platform 2020 R2).
|
||||
public const string ArchestrARootKey = @"SOFTWARE\WOW6432Node\ArchestrA";
|
||||
public const string FrameworkKey = @"SOFTWARE\WOW6432Node\ArchestrA\Framework";
|
||||
public const string PlatformKey = @"SOFTWARE\WOW6432Node\ArchestrA\Framework\Platform";
|
||||
public const string MsiInstallKey = @"SOFTWARE\WOW6432Node\ArchestrA\MSIInstall";
|
||||
|
||||
public static PrerequisiteCheck CheckFrameworkInstalled()
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Skip, "Registry probes only run on Windows.");
|
||||
}
|
||||
return FrameworkInstalledWindows();
|
||||
}
|
||||
|
||||
public static PrerequisiteCheck CheckPlatformDeployed()
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Platform", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Skip, "Registry probes only run on Windows.");
|
||||
}
|
||||
return PlatformDeployedWindows();
|
||||
}
|
||||
|
||||
public static PrerequisiteCheck CheckRebootPending()
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Skip, "Registry probes only run on Windows.");
|
||||
}
|
||||
return RebootPendingWindows();
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static PrerequisiteCheck FrameworkInstalledWindows()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(FrameworkKey);
|
||||
if (key is null)
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"Missing {FrameworkKey} — ArchestrA Framework isn't installed. Install AVEVA System Platform from the setup media.");
|
||||
}
|
||||
|
||||
var installPath = key.GetValue("InstallPath") as string;
|
||||
var rootPath = key.GetValue("RootPath") as string;
|
||||
if (string.IsNullOrWhiteSpace(installPath) || string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Framework key exists but InstallPath/RootPath values missing — install may be incomplete.");
|
||||
}
|
||||
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Pass,
|
||||
$"Installed at {installPath} (RootPath {rootPath}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Probe failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static PrerequisiteCheck PlatformDeployedWindows()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(PlatformKey);
|
||||
var pfeConfig = key?.GetValue("PfeConfigOptions") as string;
|
||||
if (string.IsNullOrWhiteSpace(pfeConfig))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"No Platform object deployed locally (Platform\\PfeConfigOptions empty). MXAccess will connect but subscriptions will fail. Deploy a Platform from the IDE.");
|
||||
}
|
||||
|
||||
// PfeConfigOptions format: "PlatformId=N,EngineId=N,EngineName=...,..."
|
||||
// A non-deployed state leaves PlatformId=0 or the key empty.
|
||||
if (pfeConfig.Contains("PlatformId=0,", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Platform never deployed (PfeConfigOptions has PlatformId=0). Deploy a Platform from the IDE before running live tests.");
|
||||
}
|
||||
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Pass,
|
||||
$"Platform deployed ({pfeConfig}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Probe failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static PrerequisiteCheck RebootPendingWindows()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(MsiInstallKey);
|
||||
var rebootRequired = key?.GetValue("RebootRequired") as string;
|
||||
if (string.Equals(rebootRequired, "True", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
"An ArchestrA patch has been installed but the machine hasn't rebooted. Post-patch behavior is undefined until a reboot.");
|
||||
}
|
||||
return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Pass,
|
||||
"No pending reboot flagged.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Probe failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the registered <see cref="ComProgIdCheck"/> CLSID for the given ProgID and
|
||||
/// resolve the 32-bit <c>InprocServer32</c> file path. Returns null when either is missing.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static (string? Clsid, string? InprocDllPath) ResolveProgIdToInproc(string progId)
|
||||
{
|
||||
using var progIdKey = Registry.ClassesRoot.OpenSubKey($@"{progId}\CLSID");
|
||||
var clsid = progIdKey?.GetValue(null) as string;
|
||||
if (string.IsNullOrWhiteSpace(clsid)) return (null, null);
|
||||
|
||||
// 32-bit COM server under Wow6432Node\CLSID\{guid}\InprocServer32 default value.
|
||||
using var inproc = Registry.LocalMachine.OpenSubKey(
|
||||
$@"SOFTWARE\Classes\WOW6432Node\CLSID\{clsid}\InprocServer32");
|
||||
var dll = inproc?.GetValue(null) as string;
|
||||
return (clsid, dll);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.ServiceProcess;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Queries the Windows Service Control Manager to report whether a named service is
|
||||
/// installed, its current state, and its start type. Non-Windows hosts return Skip.
|
||||
/// </summary>
|
||||
public static class ServiceProbe
|
||||
{
|
||||
public static PrerequisiteCheck Check(
|
||||
string serviceName,
|
||||
PrerequisiteCategory category,
|
||||
bool hardRequired,
|
||||
string whatItDoes)
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new PrerequisiteCheck(
|
||||
Name: $"service:{serviceName}",
|
||||
Category: category,
|
||||
Status: PrerequisiteStatus.Skip,
|
||||
Detail: "Service probes only run on Windows.");
|
||||
}
|
||||
|
||||
return CheckWindows(serviceName, category, hardRequired, whatItDoes);
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static PrerequisiteCheck CheckWindows(
|
||||
string serviceName, PrerequisiteCategory category, bool hardRequired, string whatItDoes)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var sc = new ServiceController(serviceName);
|
||||
// Touch the Status to force the SCM lookup; if the service doesn't exist, this throws
|
||||
// InvalidOperationException with message "Service ... was not found on computer.".
|
||||
var status = sc.Status;
|
||||
var startType = sc.StartType;
|
||||
|
||||
return status switch
|
||||
{
|
||||
ServiceControllerStatus.Running => new PrerequisiteCheck(
|
||||
$"service:{serviceName}", category, PrerequisiteStatus.Pass,
|
||||
$"Running ({whatItDoes})"),
|
||||
|
||||
// DemandStart services (like NmxSvc) that are Stopped are not necessarily a
|
||||
// failure — the master service (aaBootstrap) brings them up on demand. Treat
|
||||
// Stopped+Demand as Warn so operators know the situation but tests still proceed.
|
||||
ServiceControllerStatus.Stopped when startType == ServiceStartMode.Manual =>
|
||||
new PrerequisiteCheck(
|
||||
$"service:{serviceName}", category, PrerequisiteStatus.Warn,
|
||||
$"Installed but Stopped (start type Manual — {whatItDoes}). " +
|
||||
"Will be pulled up on demand by the master service; fine for tests."),
|
||||
|
||||
ServiceControllerStatus.Stopped => Fail(
|
||||
$"Installed but Stopped. Start with: sc.exe start {serviceName} ({whatItDoes})"),
|
||||
|
||||
_ => new PrerequisiteCheck(
|
||||
$"service:{serviceName}", category, PrerequisiteStatus.Warn,
|
||||
$"Transitional state {status} ({whatItDoes}) — try again in a few seconds."),
|
||||
};
|
||||
|
||||
PrerequisiteCheck Fail(string detail) => new(
|
||||
$"service:{serviceName}", category,
|
||||
hardRequired ? PrerequisiteStatus.Fail : PrerequisiteStatus.Warn,
|
||||
detail);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("was not found", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PrerequisiteCheck(
|
||||
$"service:{serviceName}", category,
|
||||
hardRequired ? PrerequisiteStatus.Fail : PrerequisiteStatus.Warn,
|
||||
$"Not installed ({whatItDoes}). Install the relevant System Platform component and retry.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck(
|
||||
$"service:{serviceName}", category, PrerequisiteStatus.Warn,
|
||||
$"Probe failed ({ex.GetType().Name}: {ex.Message}) — treat as unknown.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Galaxy Repository SQL side: SQL Server reachable, <c>ZB</c> database
|
||||
/// present, and at least one deployed object exists (so live tests have something to read).
|
||||
/// Reuses the Windows-auth connection string the repo code defaults to.
|
||||
/// </summary>
|
||||
public static class SqlProbe
|
||||
{
|
||||
public const string DefaultConnectionString =
|
||||
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;Connect Timeout=3;";
|
||||
|
||||
public static async Task<PrerequisiteCheck> CheckZbDatabaseAsync(
|
||||
string? connectionString = null, CancellationToken ct = default)
|
||||
{
|
||||
connectionString ??= DefaultConnectionString;
|
||||
try
|
||||
{
|
||||
using var conn = new SqlConnection(connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
// DB_ID returns null when the database doesn't exist on the connected server — distinct
|
||||
// failure mode from "server unreachable", deserves a distinct message.
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT DB_ID('ZB')";
|
||||
var dbIdObj = await cmd.ExecuteScalarAsync(ct);
|
||||
if (dbIdObj is null || dbIdObj is DBNull)
|
||||
{
|
||||
return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Fail,
|
||||
"SQL Server reachable but database ZB does not exist. " +
|
||||
"Create the Galaxy from the IDE or restore a .cab backup.");
|
||||
}
|
||||
|
||||
return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Pass, "Connected; ZB database exists.");
|
||||
}
|
||||
catch (SqlException ex)
|
||||
{
|
||||
return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"SQL Server unreachable: {ex.Message}. Ensure MSSQLSERVER service is running (sc.exe start MSSQLSERVER) and TCP 1433 is open.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"Unexpected probe error: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of deployed Galaxy objects (<c>deployed_version > 0</c>). Zero
|
||||
/// isn't a hard failure — lets someone boot a fresh Galaxy and still get meaningful
|
||||
/// test-suite output — but it IS a warning because any live-read smoke will have
|
||||
/// nothing to read.
|
||||
/// </summary>
|
||||
public static async Task<PrerequisiteCheck> CheckDeployedObjectCountAsync(
|
||||
string? connectionString = null, CancellationToken ct = default)
|
||||
{
|
||||
connectionString ??= DefaultConnectionString;
|
||||
try
|
||||
{
|
||||
using var conn = new SqlConnection(connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM gobject WHERE deployed_version > 0";
|
||||
var countObj = await cmd.ExecuteScalarAsync(ct);
|
||||
var count = countObj is int i ? i : 0;
|
||||
|
||||
return count > 0
|
||||
? new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Pass, $"{count} objects deployed — live reads have data to return.")
|
||||
: new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Warn,
|
||||
"ZB contains no deployed objects. Discovery smoke tests will return empty hierarchies; " +
|
||||
"deploy at least a Platform + AppEngine from the IDE to exercise the read path.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Couldn't count deployed objects: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Multi-target: net10.0 for modern consumer projects (Galaxy.Proxy.Tests, E2E, Admin.Tests),
|
||||
net48 for the Galaxy.Host.Tests project that has to stay on .NET Framework x86 for its
|
||||
MXAccess-COM parent project. The helper uses no OS-level APIs that differ between the
|
||||
two frameworks (registry / SQL / ServiceController are surface-compatible). -->
|
||||
<TargetFrameworks>net10.0;net48</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
|
||||
<!-- System.ServiceProcess.ServiceController + Microsoft.Win32.Registry are cross-platform
|
||||
assemblies that throw PlatformNotSupportedException on non-Windows; the probes in
|
||||
this project guard with RuntimeInformation.IsOSPlatform(OSPlatform.Windows) so they
|
||||
return Skip on Linux/macOS rather than crashing the test host. -->
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0"/>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
|
||||
<!-- net48 ships System.ServiceProcess + Microsoft.Win32 in-box via BCL references. -->
|
||||
<Reference Include="System.ServiceProcess"/>
|
||||
<!-- Microsoft.Data.SqlClient v6 supports net462+; single-target for consistency. -->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,56 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies DL205/DL260 binary-coded-decimal register handling against the
|
||||
/// <c>dl205.json</c> pymodbus profile. HR[1072] = 0x1234 on the profile represents
|
||||
/// decimal 1234 (BCD nibbles). Reading it as <see cref="ModbusDataType.Int16"/> would
|
||||
/// return 0x1234 = 4660; the <see cref="ModbusDataType.Bcd16"/> path decodes 1234.
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205BcdQuirkTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL205_BCD16_decodes_HR1072_as_decimal_1234()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping (standard profile does not seed HR[1072]).");
|
||||
}
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition("DL205_Count_Bcd",
|
||||
ModbusRegion.HoldingRegisters, Address: 1072,
|
||||
DataType: ModbusDataType.Bcd16, Writable: false),
|
||||
new ModbusTagDefinition("DL205_Count_Int16",
|
||||
ModbusRegion.HoldingRegisters, Address: 1072,
|
||||
DataType: ModbusDataType.Int16, Writable: false),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-bcd");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL205_Count_Bcd", "DL205_Count_Int16"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe(1234, "DL205 BCD register 0x1234 represents decimal 1234 per the DirectLOGIC convention");
|
||||
|
||||
results[1].StatusCode.ShouldBe(0u);
|
||||
results[1].Value.ShouldBe((short)0x1234, "same register read as Int16 returns the raw 0x1234 = 4660 value — proves BCD path is distinct");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies DL260 I/O-memory coil mappings against the <c>dl205.json</c> pymodbus profile.
|
||||
/// DirectLOGIC Y-outputs and C-relays are exposed to Modbus as FC01/FC05 coils, but at
|
||||
/// non-zero base addresses that confuse operators used to "Y0 is the first coil". The sim
|
||||
/// seeds Y0 → coil 2048 = ON and C0 → coil 3072 = ON as fixed markers.
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205CoilMappingTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL260_Y0_maps_to_coil_2048()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
|
||||
}
|
||||
|
||||
var coil = DirectLogicAddress.YOutputToCoil("Y0");
|
||||
coil.ShouldBe((ushort)2048);
|
||||
|
||||
var options = BuildOptions(sim, [
|
||||
new ModbusTagDefinition("DL260_Y0",
|
||||
ModbusRegion.Coils, Address: coil,
|
||||
DataType: ModbusDataType.Bool, Writable: false),
|
||||
]);
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-y0");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL260_Y0"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe(true, "dl205.json seeds coil 2048 (Y0) = ON");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DL260_C0_maps_to_coil_3072()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
|
||||
}
|
||||
|
||||
var coil = DirectLogicAddress.CRelayToCoil("C0");
|
||||
coil.ShouldBe((ushort)3072);
|
||||
|
||||
var options = BuildOptions(sim, [
|
||||
new ModbusTagDefinition("DL260_C0",
|
||||
ModbusRegion.Coils, Address: coil,
|
||||
DataType: ModbusDataType.Bool, Writable: false),
|
||||
]);
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-c0");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL260_C0"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe(true, "dl205.json seeds coil 3072 (C0) = ON");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DL260_scratch_Crelay_supports_write_then_read()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
|
||||
}
|
||||
|
||||
// Scratch C-relay at coil 4000 (per dl205.json _quirk note) is writable. Write=true then
|
||||
// read back to confirm FC05 round-trip works against the DL-mapped coil bank.
|
||||
var options = BuildOptions(sim, [
|
||||
new ModbusTagDefinition("DL260_C_Scratch",
|
||||
ModbusRegion.Coils, Address: 4000,
|
||||
DataType: ModbusDataType.Bool, Writable: true),
|
||||
]);
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-cscratch");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var writeResults = await driver.WriteAsync(
|
||||
[new(FullReference: "DL260_C_Scratch", Value: true)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writeResults[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
var readResults = await driver.ReadAsync(["DL260_C_Scratch"], TestContext.Current.CancellationToken);
|
||||
readResults[0].StatusCode.ShouldBe(0u);
|
||||
readResults[0].Value.ShouldBe(true);
|
||||
}
|
||||
|
||||
private static ModbusDriverOptions BuildOptions(ModbusSimulatorFixture sim, IReadOnlyList<ModbusTagDefinition> tags)
|
||||
=> new()
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags = tags,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies DL205/DL260 CDAB word ordering for 32-bit floats against the
|
||||
/// <c>dl205.json</c> pymodbus profile. DirectLOGIC stores IEEE-754 singles with the low
|
||||
/// word at the lower register address (CDAB) rather than the high word (ABCD). Reading
|
||||
/// <c>HR[1056..1057]</c> with <see cref="ModbusByteOrder.BigEndian"/> produces a tiny
|
||||
/// denormal (~5.74e-41) instead of the intended 1.5f — a silent "value is 0" bug in the
|
||||
/// field unless the caller opts into <see cref="ModbusByteOrder.WordSwap"/>.
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205FloatCdabQuirkTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL205_Float32_CDAB_decodes_1_5f_from_HR1056()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping (standard profile does not seed HR[1056..1057]).");
|
||||
}
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition("DL205_Float_CDAB",
|
||||
ModbusRegion.HoldingRegisters, Address: 1056,
|
||||
DataType: ModbusDataType.Float32, Writable: false,
|
||||
ByteOrder: ModbusByteOrder.WordSwap),
|
||||
// Control: same address, BigEndian — proves the default decode produces garbage.
|
||||
new ModbusTagDefinition("DL205_Float_ABCD",
|
||||
ModbusRegion.HoldingRegisters, Address: 1056,
|
||||
DataType: ModbusDataType.Float32, Writable: false,
|
||||
ByteOrder: ModbusByteOrder.BigEndian),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-cdab");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL205_Float_CDAB", "DL205_Float_ABCD"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe(1.5f, "DL205 Float32 with WordSwap (CDAB) must decode HR[1056..1057] as 1.5f");
|
||||
|
||||
// The BigEndian read of the same wire bytes should differ — not asserting the exact
|
||||
// denormal value (that couples the test to IEEE-754 bit math) but the two decodes MUST
|
||||
// disagree, otherwise the word-order flag is a no-op.
|
||||
results[1].StatusCode.ShouldBe(0u);
|
||||
results[1].Value.ShouldNotBe(1.5f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Tag map for the AutomationDirect DL205 device class. Mirrors what the pymodbus
|
||||
/// <c>dl205.json</c> profile in <c>Pymodbus/dl205.json</c> exposes (or the real PLC, when
|
||||
/// <see cref="ModbusSimulatorFixture"/> is pointed at one).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the scaffold — each tag is deliberately generic so the smoke test has stable
|
||||
/// addresses to read. Device-specific quirk tests (word order, max-register, register-zero
|
||||
/// access, etc.) will land in their own test classes alongside this profile as the user
|
||||
/// validates each behavior in pymodbus; see <c>docs/v2/modbus-test-plan.md</c> §per-device
|
||||
/// quirk catalog for the checklist.
|
||||
/// </remarks>
|
||||
public static class DL205Profile
|
||||
{
|
||||
/// <summary>
|
||||
/// Holding register the smoke test writes + reads. Address 200 is the first cell of the
|
||||
/// scratch HR range in both <c>Pymodbus/standard.json</c> (HR[200..209] = 0) and
|
||||
/// <c>Pymodbus/dl205.json</c> (HR[4096..4103] added in PR 43 for the same purpose), so
|
||||
/// the smoke test runs identically against either simulator profile. Originally
|
||||
/// targeted HR[100] — moved to HR[200] when the standard profile claimed HR[100] as
|
||||
/// the auto-incrementing register that drives subscribe-and-receive tests.
|
||||
/// </summary>
|
||||
public const ushort SmokeHoldingRegister = 200;
|
||||
|
||||
/// <summary>Value the smoke test writes then reads back to assert round-trip integrity.</summary>
|
||||
public const short SmokeHoldingValue = 1234;
|
||||
|
||||
public static ModbusDriverOptions BuildOptions(string host, int port) => new()
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition(
|
||||
Name: "Smoke_HReg200",
|
||||
Region: ModbusRegion.HoldingRegisters,
|
||||
Address: SmokeHoldingRegister,
|
||||
DataType: ModbusDataType.Int16,
|
||||
Writable: true),
|
||||
],
|
||||
// Disable the background probe loop — integration tests drive reads explicitly and
|
||||
// the probe would race with assertions.
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end smoke against the DL205 ModbusPal profile (or a real DL205 when
|
||||
/// <c>MODBUS_SIM_ENDPOINT</c> points at one). Drives the full <see cref="ModbusDriver"/>
|
||||
/// + real <see cref="ModbusTcpTransport"/> stack — no fake transport. Success proves the
|
||||
/// driver can initialize against the simulator, write a known value, and read it back
|
||||
/// with the correct status and value, which is the baseline every device-quirk test
|
||||
/// builds on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Device-specific quirk tests (word order, max-register, register-zero access, exception
|
||||
/// code translation, etc.) land as separate test classes in this directory as each quirk
|
||||
/// is validated in ModbusPal. Keep this smoke test deliberately narrow — any deviation
|
||||
/// the driver hits beyond "happy-path FC16 + FC03 round-trip" belongs in its own named
|
||||
/// test so filtering by device class (<c>--filter DisplayName~DL205</c>) surfaces the
|
||||
/// quirk-specific failure mode.
|
||||
/// </remarks>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205SmokeTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL205_roundtrip_write_then_read_of_holding_register()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
var options = DL205Profile.BuildOptions(sim.Host, sim.Port);
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-smoke");
|
||||
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Write first so the test is self-contained — ModbusPal's default register bank is
|
||||
// zeroed at simulator start, and tests must not depend on prior-test state per the
|
||||
// test-plan conventions.
|
||||
var writeResults = await driver.WriteAsync(
|
||||
[new(FullReference: "Smoke_HReg200", Value: (short)DL205Profile.SmokeHoldingValue)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writeResults.Count.ShouldBe(1);
|
||||
writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the ModbusPal DL205 profile");
|
||||
|
||||
var readResults = await driver.ReadAsync(
|
||||
["Smoke_HReg200"],
|
||||
TestContext.Current.CancellationToken);
|
||||
readResults.Count.ShouldBe(1);
|
||||
readResults[0].StatusCode.ShouldBe(0u);
|
||||
readResults[0].Value.ShouldBe((short)DL205Profile.SmokeHoldingValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the DL205/DL260 low-byte-first ASCII string packing quirk against the
|
||||
/// <c>dl205.json</c> pymodbus profile. Standard Modbus packs the first char of each pair
|
||||
/// in the high byte of the register; DirectLOGIC packs it in the low byte instead. Without
|
||||
/// <see cref="ModbusStringByteOrder.LowByteFirst"/> the driver decodes "eHllo" garbage
|
||||
/// even though the bytes on the wire are identical.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Requires the dl205 profile (<c>Pymodbus\serve.ps1 -Profile dl205</c>). The standard
|
||||
/// profile does not seed HR[1040..1042] with string bytes, so running this against the
|
||||
/// standard profile returns <c>"\0\0\0\0\0"</c> and the test fails. Skip when the env
|
||||
/// var <c>MODBUS_SIM_PROFILE</c> is not set to <c>dl205</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205StringQuirkTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL205_string_low_byte_first_decodes_Hello_from_HR1040()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping (standard profile does not seed HR[1040..1042]).");
|
||||
}
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition(
|
||||
Name: "DL205_Hello_Low",
|
||||
Region: ModbusRegion.HoldingRegisters,
|
||||
Address: 1040,
|
||||
DataType: ModbusDataType.String,
|
||||
Writable: false,
|
||||
StringLength: 5,
|
||||
StringByteOrder: ModbusStringByteOrder.LowByteFirst),
|
||||
// Control: same address, HighByteFirst, to prove the driver would have decoded
|
||||
// garbage without the quirk flag.
|
||||
new ModbusTagDefinition(
|
||||
Name: "DL205_Hello_High",
|
||||
Region: ModbusRegion.HoldingRegisters,
|
||||
Address: 1040,
|
||||
DataType: ModbusDataType.String,
|
||||
Writable: false,
|
||||
StringLength: 5,
|
||||
StringByteOrder: ModbusStringByteOrder.HighByteFirst),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-string");
|
||||
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL205_Hello_Low", "DL205_Hello_High"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe("Hello", "DL205 low-byte-first ordering must produce 'Hello' from HR[1040..1042]");
|
||||
|
||||
// The high-byte-first read of the same wire bytes should differ — not asserting the
|
||||
// exact garbage string (that would couple the test to the ASCII byte math) but the two
|
||||
// decodes MUST disagree, otherwise the quirk flag is a no-op.
|
||||
results[1].StatusCode.ShouldBe(0u);
|
||||
results[1].Value.ShouldNotBe("Hello");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the DL205/DL260 V-memory octal addressing quirk end-to-end: use
|
||||
/// <see cref="DirectLogicAddress.UserVMemoryToPdu"/> to translate <c>V2000</c> octal into
|
||||
/// the Modbus PDU address actually dispatched, then read the marker the dl205.json profile
|
||||
/// placed at that address. HR[0x0400] = 0x2000 proves the translation was performed
|
||||
/// correctly — a naïve caller treating "V2000" as decimal 2000 would read HR[2000] (which
|
||||
/// the profile leaves at 0) and miss the marker entirely.
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205VMemoryQuirkTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL205_V2000_user_memory_resolves_to_PDU_0x0400_marker()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping (standard profile does not seed V-memory markers).");
|
||||
}
|
||||
|
||||
var pdu = DirectLogicAddress.UserVMemoryToPdu("V2000");
|
||||
pdu.ShouldBe((ushort)0x0400);
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition("DL205_V2000",
|
||||
ModbusRegion.HoldingRegisters, Address: pdu,
|
||||
DataType: ModbusDataType.UInt16, Writable: false),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-vmem");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL205_V2000"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe((ushort)0x2000, "dl205.json seeds HR[0x0400] with marker 0x2000 (= V2000 value)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DL205_V40400_system_memory_resolves_to_PDU_0x2100_marker()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
|
||||
}
|
||||
|
||||
// V40400 is system memory on DL260 / H2-ECOM100 absolute mode; it does NOT follow the
|
||||
// simple octal-to-decimal formula (40400 octal = 16640 decimal, which would read HR[0x4100]).
|
||||
// The CPU places the system bank at PDU 0x2100 instead. Proving the helper routes there.
|
||||
var pdu = DirectLogicAddress.SystemVMemoryToPdu(0);
|
||||
pdu.ShouldBe((ushort)0x2100);
|
||||
|
||||
var options = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition("DL205_V40400",
|
||||
ModbusRegion.HoldingRegisters, Address: pdu,
|
||||
DataType: ModbusDataType.UInt16, Writable: false),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-sysv");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL205_V40400"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe((ushort)0x4040, "dl205.json seeds HR[0x2100] with marker 0x4040 (= V40400 value)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the DL260 X-input discrete-input mapping against the <c>dl205.json</c>
|
||||
/// pymodbus profile. X-inputs are FC02 discrete-input-only (Modbus doesn't allow writes
|
||||
/// to discrete inputs), and the DirectLOGIC convention is X0 → DI 0 with octal offsets
|
||||
/// for subsequent addresses. The sim seeds X20 octal (= DI 16) = ON so the test can
|
||||
/// prove the helper routes through to the right cell.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// X0 / X1 / …X17 octal all share cell 0 (DI 0-15 → cell 0 bits 0-15) which conflicts
|
||||
/// with the V0 uint16 marker; we can't seed both types at cell 0 under shared-blocks
|
||||
/// semantics. So the test uses X20 octal (first address beyond the cell-0 boundary) which
|
||||
/// lands cleanly at cell 1 bit 0 and leaves the V0 register-zero quirk intact.
|
||||
/// </remarks>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "DL205")]
|
||||
public sealed class DL205XInputTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task DL260_X20_octal_maps_to_DiscreteInput_16_and_reads_ON()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
|
||||
}
|
||||
|
||||
// X20 octal = decimal 16 = DI 16 per the DL260 convention (X-inputs start at DI 0).
|
||||
var di = DirectLogicAddress.XInputToDiscrete("X20");
|
||||
di.ShouldBe((ushort)16);
|
||||
|
||||
var options = BuildOptions(sim, [
|
||||
new ModbusTagDefinition("DL260_X20",
|
||||
ModbusRegion.DiscreteInputs, Address: di,
|
||||
DataType: ModbusDataType.Bool, Writable: false),
|
||||
// Unpopulated-X control: pymodbus returns 0 (not exception) for any bit in the
|
||||
// configured DI range that wasn't explicitly seeded — per docs/v2/dl205.md
|
||||
// "Reading a non-populated X input ... returns zero, not an exception".
|
||||
new ModbusTagDefinition("DL260_X21_off",
|
||||
ModbusRegion.DiscreteInputs, Address: DirectLogicAddress.XInputToDiscrete("X21"),
|
||||
DataType: ModbusDataType.Bool, Writable: false),
|
||||
]);
|
||||
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-xinput");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["DL260_X20", "DL260_X21_off"], TestContext.Current.CancellationToken);
|
||||
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
results[0].Value.ShouldBe(true, "dl205.json seeds cell 1 bit 0 (X20 octal = DI 16) = ON");
|
||||
|
||||
results[1].StatusCode.ShouldBe(0u, "unpopulated X inputs must read cleanly — DL260 does NOT raise an exception");
|
||||
results[1].Value.ShouldBe(false);
|
||||
}
|
||||
|
||||
private static ModbusDriverOptions BuildOptions(ModbusSimulatorFixture sim, IReadOnlyList<ModbusTagDefinition> tags)
|
||||
=> new()
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags = tags,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability probe for a Modbus TCP simulator (pymodbus-driven, see
|
||||
/// <c>Pymodbus/serve.ps1</c>) or a real PLC. Parses
|
||||
/// <c>MODBUS_SIM_ENDPOINT</c> (default <c>localhost:5020</c> per PR 43) and TCP-connects once at
|
||||
/// fixture construction. Each test checks <see cref="SkipReason"/> and calls
|
||||
/// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running
|
||||
/// simulator still passes `dotnet test` cleanly — matches the Galaxy live-smoke pattern in
|
||||
/// <c>GalaxyRepositoryLiveSmokeTests</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Do NOT keep the probe socket open for the life of the fixture. The probe is a
|
||||
/// one-shot liveness check; tests open their own transports (the real
|
||||
/// <see cref="ModbusTcpTransport"/>) against the same endpoint. Sharing a socket
|
||||
/// across tests would serialize them on a single TCP stream.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The fixture is a collection fixture so the reachability probe runs once per test
|
||||
/// session, not per test — checking every test would waste several seconds against a
|
||||
/// firewalled endpoint that times out each attempt.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ModbusSimulatorFixture : IAsyncDisposable
|
||||
{
|
||||
// PR 43: default port is 5020 (pymodbus convention) instead of 502 (Modbus standard).
|
||||
// Picking 5020 sidesteps the privileged-port admin requirement on Windows + matches the
|
||||
// port baked into the pymodbus simulator JSON profiles in Pymodbus/. Override with
|
||||
// MODBUS_SIM_ENDPOINT to point at a real PLC on its native port 502.
|
||||
private const string DefaultEndpoint = "localhost:5020";
|
||||
private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT";
|
||||
|
||||
public string Host { get; }
|
||||
public int Port { get; }
|
||||
public string? SkipReason { get; }
|
||||
|
||||
public ModbusSimulatorFixture()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
|
||||
var parts = raw.Split(':', 2);
|
||||
Host = parts[0];
|
||||
Port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 502;
|
||||
|
||||
try
|
||||
{
|
||||
// Force IPv4 family on the probe — pymodbus's TCP server binds 0.0.0.0 (IPv4 only)
|
||||
// while .NET's TcpClient default-resolves "localhost" → IPv6 ::1 first, fails to
|
||||
// connect, and only then tries IPv4. Under .NET 10 the IPv6 fail surfaces as a
|
||||
// 2s timeout (no graceful fallback by default), so the C# probe times out even
|
||||
// though a PowerShell probe of the same endpoint succeeds. Resolving + dialing
|
||||
// explicit IPv4 sidesteps the dual-stack ordering.
|
||||
using var client = new TcpClient(System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var task = client.ConnectAsync(
|
||||
System.Net.Dns.GetHostAddresses(Host)
|
||||
.FirstOrDefault(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
?? System.Net.IPAddress.Loopback,
|
||||
Port);
|
||||
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
|
||||
{
|
||||
SkipReason = $"Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
|
||||
$"Start the pymodbus simulator (Pymodbus\\serve.ps1 -Profile standard) " +
|
||||
$"or override {EndpointEnvVar}, then re-run.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SkipReason = $"Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
|
||||
$"Start the pymodbus simulator (Pymodbus\\serve.ps1 -Profile standard) " +
|
||||
$"or override {EndpointEnvVar}, then re-run.";
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Xunit.CollectionDefinition(Name)]
|
||||
public sealed class ModbusSimulatorCollection : Xunit.ICollectionFixture<ModbusSimulatorFixture>
|
||||
{
|
||||
public const string Name = "ModbusSimulator";
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
# pymodbus simulator profiles
|
||||
|
||||
Two JSON-config profiles for pymodbus's `ModbusSimulatorServer`. Replaces the
|
||||
ModbusPal `.xmpp` profiles that lived here in PR 42 — pymodbus is headless,
|
||||
maintained, semantic about register layout, and pip-installable on Windows.
|
||||
|
||||
| File | What it simulates | Test category |
|
||||
|---|---|---|
|
||||
| [`standard.json`](standard.json) | Generic Modbus TCP server — HR[0..31] = address-as-value, HR[100] declarative auto-increment via `"action": "increment"`, alternating coils, scratch ranges for write tests. | `Trait=Standard` |
|
||||
| [`dl205.json`](dl205.json) | AutomationDirect DirectLOGIC DL205 / DL260 quirks per [`docs/v2/dl205.md`](../../../docs/v2/dl205.md): low-byte-first string packing, CDAB Float32, BCD numerics, V-memory address markers, Y/C coil mappings. Inline `_quirk` comments per register name the behavior. | `Trait=DL205` |
|
||||
|
||||
Both bind TCP **5020** (pymodbus convention; sidesteps the Windows admin
|
||||
requirement for privileged port 502). The integration-test fixture
|
||||
(`ModbusSimulatorFixture`) defaults to `localhost:5020` to match — override
|
||||
via `MODBUS_SIM_ENDPOINT` to point at a real PLC on its native port 502.
|
||||
|
||||
Run only **one profile at a time** (they share TCP 5020).
|
||||
|
||||
## Install
|
||||
|
||||
```powershell
|
||||
pip install "pymodbus[simulator]==3.13.0"
|
||||
```
|
||||
|
||||
The `[simulator]` extra pulls in `aiohttp` for the optional web UI / REST API.
|
||||
Pinned to 3.13.0 for reproducibility — avoid 4.x dev releases until stabilized.
|
||||
Requires Python ≥ 3.10. Windows Firewall will prompt on first bind; allow
|
||||
Private network.
|
||||
|
||||
## Run
|
||||
|
||||
Foreground (Ctrl+C to stop). Use the `serve.ps1` wrapper:
|
||||
|
||||
```powershell
|
||||
.\serve.ps1 -Profile standard
|
||||
.\serve.ps1 -Profile dl205
|
||||
```
|
||||
|
||||
Or invoke pymodbus directly:
|
||||
|
||||
```powershell
|
||||
pymodbus.simulator `
|
||||
--modbus_server srv `
|
||||
--modbus_device dev `
|
||||
--json_file .\standard.json `
|
||||
--http_port 8080
|
||||
```
|
||||
|
||||
Web UI at `http://localhost:8080` lets you inspect + poke registers manually.
|
||||
Pass `--no_http` (or `-HttpPort 0` to `serve.ps1`) to disable.
|
||||
|
||||
## Run the integration tests
|
||||
|
||||
In a separate shell, with the simulator running:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\dohertj2\Desktop\lmxopcua
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests
|
||||
```
|
||||
|
||||
Tests auto-skip with a clear `SkipReason` if `localhost:5020` isn't reachable
|
||||
within 2 seconds. Filter by trait when both profiles' tests coexist:
|
||||
|
||||
```powershell
|
||||
dotnet test ... --filter "Trait=Standard"
|
||||
dotnet test ... --filter "Trait=DL205"
|
||||
```
|
||||
|
||||
## What's encoded in each profile
|
||||
|
||||
### standard.json
|
||||
|
||||
- HR[0..31]: each register's value equals its address. Easy mental map.
|
||||
- HR[100]: `"action": "increment"` ticks 0..65535 on every register access — drives subscribe-and-receive tests so they have a register that changes without a write.
|
||||
- HR[200..209]: scratch range for write-roundtrip tests.
|
||||
- Coils[0..31]: alternating on/off (even=on).
|
||||
- Coils[100..109]: scratch.
|
||||
- All addresses 0..1023 are writable (`"write": [[0, 1023]]`).
|
||||
|
||||
### dl205.json (per `docs/v2/dl205.md`)
|
||||
|
||||
| HR address | Quirk demonstrated | Raw value | Decoded |
|
||||
|---|---|---|---|
|
||||
| `0` (V0) | Register 0 is valid (rejects-register-0 rumour disproved) | `51966` (0xCAFE) | marker |
|
||||
| `1024` (V2000 octal) | V-memory octal-to-decimal mapping | `8192` (0x2000) | marker |
|
||||
| `8448` (V40400 octal) | V40400 → PDU 0x2100 (NOT register 0) | `16448` (0x4040) | marker |
|
||||
| `1040..1042` | String "Hello" packed first-char-low-byte | `25928, 27756, 111` | `"Hello"` |
|
||||
| `1056..1057` | Float32 1.5f in CDAB word order | `0, 16320` | `1.5f` |
|
||||
| `1072` | Decimal 1234 in BCD encoding | `4660` (0x1234) | `1234` |
|
||||
| `1280..1407` | 128-register block (FC03 cap = 128 above spec's 125) | first/last/mid markers; rest defaults to 0 | for FC03 cap test |
|
||||
|
||||
| Coil address | Quirk demonstrated |
|
||||
|---|---|
|
||||
| `2048` | Y0 maps to coil 2048 (DL260 layout) |
|
||||
| `3072` | C0 maps to coil 3072 (DL260 layout) |
|
||||
| `4000..4007` | Scratch C-relay range for write-roundtrip tests |
|
||||
|
||||
The DL260 X-input markers (FC02 discrete inputs) **are not encoded separately**
|
||||
because the profile uses `shared blocks: true` (matches DL series memory
|
||||
model) — coils/DI/HR/IR overlay the same word address space. Tests that
|
||||
target FC02 against this profile end up reading the same bit positions as
|
||||
the coils they share with.
|
||||
|
||||
## What's IN pymodbus that wasn't in ModbusPal
|
||||
|
||||
- **All four standard tables** (HR, IR, coils, DI) configurable via `co size` / `di size` / `hr size` / `ir size` setup keys.
|
||||
- **Per-register raw uint16 seeding** — `{"addr": 1040, "value": 25928}` puts exactly that 16-bit value on the wire. No interpretation.
|
||||
- **Built-in actions**: `increment`, `random`, `timestamp`, `reset`, `uptime` for declarative dynamic registers. No Python script alongside the config required.
|
||||
- **Custom actions** — point `--custom_actions_module` at a `.py` file exposing callables to express anything more complex (per-second wall-clock ticks, BCD synthesis, etc.).
|
||||
- **Headless** — pure CLI process, no Java, no Swing. Pip-installable. Plays well with CI runners.
|
||||
- **Web UI / REST API** — `--http_port 8080` adds an aiohttp server for live inspection. Optional.
|
||||
- **Maintained** — current stable 3.13.0 (April 2026), active development on 4.0 dev branch.
|
||||
|
||||
## Trade-offs vs the hand-authored ModbusPal profiles
|
||||
|
||||
- pymodbus's built-in `float32` type stores in pymodbus's word order; for explicit DL205 CDAB control we seed two raw `uint16` entries instead. Documented inline in `dl205.json`.
|
||||
- `increment` action ticks per-access, not wall-clock. A 250ms-poll integration test sees variation either way; for strict 1Hz cadence add `--custom_actions_module my_actions.py` with a `time.time()`-based callable.
|
||||
- `dl205.json` uses `shared blocks: true` because it matches DL series memory model; `standard.json` uses `shared blocks: false` so coils and HR address spaces are independent (more like a textbook PLC).
|
||||
|
||||
## File format reference
|
||||
|
||||
```json
|
||||
{
|
||||
"server_list": {
|
||||
"<server-name>": {
|
||||
"comm": "tcp",
|
||||
"host": "0.0.0.0",
|
||||
"port": 5020,
|
||||
"framer": "socket",
|
||||
"device_id": 1
|
||||
}
|
||||
},
|
||||
"device_list": {
|
||||
"<device-name>": {
|
||||
"setup": {
|
||||
"co size": N, "di size": N, "hr size": N, "ir size": N,
|
||||
"shared blocks": false,
|
||||
"type exception": false,
|
||||
"defaults": { "value": {...}, "action": {...} }
|
||||
},
|
||||
"invalid": [],
|
||||
"write": [[<from>, <to>]],
|
||||
"bits": [{"addr": N, "value": 0|1}],
|
||||
"uint16": [{"addr": N, "value": <0..65535>, "action"?: "increment", "parameters"?: {...}}],
|
||||
"uint32": [{"addr": N, "value": <int>}],
|
||||
"float32": [{"addr": N, "value": <float>}],
|
||||
"string": [{"addr": N, "value": "<text>"}],
|
||||
"repeat": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The CLI args `--modbus_server <server-name> --modbus_device <device-name>`
|
||||
pick which entries the simulator binds.
|
||||
|
||||
## References
|
||||
|
||||
- [pymodbus on PyPI](https://pypi.org/project/pymodbus/) — install, version pin
|
||||
- [Simulator config docs](https://pymodbus.readthedocs.io/en/dev/source/library/simulator/config.html) — full schema reference
|
||||
- [Simulator REST API](https://pymodbus.readthedocs.io/en/latest/source/library/simulator/restapi.html) — for the optional web UI
|
||||
- [`docs/v2/dl205.md`](../../../docs/v2/dl205.md) — what each DL205 profile entry simulates
|
||||
- [`docs/v2/modbus-test-plan.md`](../../../docs/v2/modbus-test-plan.md) — the `DL205_<behavior>` test naming convention
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"_comment": "DL205.json — DirectLOGIC DL205/DL260 quirk simulator. Models docs/v2/dl205.md as concrete register values. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live at top-level _comment + in README + git. Inline _quirk keys WITHIN individual register entries are accepted by pymodbus 3.13.0 (it only validates addr / value / action / parameters per entry). Each quirky uint16 is a pre-computed raw 16-bit value; pymodbus serves it verbatim. shared blocks=true matches DL series memory model. write list mirrors each seeded block — pymodbus rejects sweeping write ranges that include undefined cells.",
|
||||
|
||||
"server_list": {
|
||||
"srv": {
|
||||
"comm": "tcp",
|
||||
"host": "0.0.0.0",
|
||||
"port": 5020,
|
||||
"framer": "socket",
|
||||
"device_id": 1
|
||||
}
|
||||
},
|
||||
|
||||
"device_list": {
|
||||
"dev": {
|
||||
"setup": {
|
||||
"co size": 16384,
|
||||
"di size": 8192,
|
||||
"hr size": 16384,
|
||||
"ir size": 1024,
|
||||
"shared blocks": true,
|
||||
"type exception": false,
|
||||
"defaults": {
|
||||
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
|
||||
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
|
||||
}
|
||||
},
|
||||
"invalid": [],
|
||||
"write": [
|
||||
[0, 0],
|
||||
[200, 209],
|
||||
[1024, 1024],
|
||||
[1040, 1042],
|
||||
[1056, 1057],
|
||||
[1072, 1072],
|
||||
[1280, 1282],
|
||||
[1343, 1343],
|
||||
[1407, 1407],
|
||||
[1, 1],
|
||||
[128, 128],
|
||||
[192, 192],
|
||||
[250, 250],
|
||||
[8448, 8448]
|
||||
],
|
||||
|
||||
"uint16": [
|
||||
{"_quirk": "V0 marker. HR[0]=0xCAFE proves register 0 is valid on DL205/DL260 (rejects-register-0 was a DL05/DL06 relative-mode artefact). 0xCAFE = 51966.",
|
||||
"addr": 0, "value": 51966},
|
||||
|
||||
{"_quirk": "Scratch HR range 200..209 — mirrors the standard.json scratch range so the smoke test (DL205Profile.SmokeHoldingRegister=200) round-trips identically against either profile.",
|
||||
"addr": 200, "value": 0},
|
||||
{"addr": 201, "value": 0},
|
||||
{"addr": 202, "value": 0},
|
||||
{"addr": 203, "value": 0},
|
||||
{"addr": 204, "value": 0},
|
||||
{"addr": 205, "value": 0},
|
||||
{"addr": 206, "value": 0},
|
||||
{"addr": 207, "value": 0},
|
||||
{"addr": 208, "value": 0},
|
||||
{"addr": 209, "value": 0},
|
||||
|
||||
{"_quirk": "V2000 marker. V2000 octal = decimal 1024 = PDU 0x0400. Marker 0x2000 = 8192.",
|
||||
"addr": 1024, "value": 8192},
|
||||
|
||||
{"_quirk": "V40400 marker. V40400 octal = decimal 8448 = PDU 0x2100 (NOT register 0). Marker 0x4040 = 16448.",
|
||||
"addr": 8448, "value": 16448},
|
||||
|
||||
{"_quirk": "String 'Hello' first char in LOW byte. HR[0x410] = 'H'(0x48) lo + 'e'(0x65) hi = 0x6548 = 25928.",
|
||||
"addr": 1040, "value": 25928},
|
||||
{"_quirk": "String 'Hello' second char-pair: 'l'(0x6C) lo + 'l'(0x6C) hi = 0x6C6C = 27756.",
|
||||
"addr": 1041, "value": 27756},
|
||||
{"_quirk": "String 'Hello' third char-pair: 'o'(0x6F) lo + null(0x00) hi = 0x006F = 111.",
|
||||
"addr": 1042, "value": 111},
|
||||
|
||||
{"_quirk": "Float32 1.5f in CDAB word order. IEEE 754 1.5 = 0x3FC00000. CDAB = low word first: HR[0x420]=0x0000, HR[0x421]=0x3FC0=16320.",
|
||||
"addr": 1056, "value": 0},
|
||||
{"_quirk": "Float32 1.5f CDAB high word.",
|
||||
"addr": 1057, "value": 16320},
|
||||
|
||||
{"_quirk": "BCD register. Decimal 1234 stored as BCD nibbles 0x1234 = 4660. NOT binary 1234 (= 0x04D2).",
|
||||
"addr": 1072, "value": 4660},
|
||||
|
||||
{"_quirk": "FC03 cap test marker — first cell of a 128-register span the FC03 cap test reads. Other cells in the span aren't seeded explicitly, so reads of HR[1283..1342] / 1344..1406 return the default 0; the seeded markers at 1280, 1281, 1282, 1343, 1407 prove the span boundaries.",
|
||||
"addr": 1280, "value": 0},
|
||||
{"addr": 1281, "value": 1},
|
||||
{"addr": 1282, "value": 2},
|
||||
{"addr": 1343, "value": 63},
|
||||
{"addr": 1407, "value": 127}
|
||||
],
|
||||
|
||||
"bits": [
|
||||
{"_quirk": "X-input bank marker cell. X0 -> DI 0 conflicts with uint16 V0 at cell 0, so this marker covers X20 octal (= decimal 16 = DI 16 = cell 1 bit 0). X20=ON, X23 octal (DI 19 = cell 1 bit 3)=ON -> cell 1 value = 0b00001001 = 9.",
|
||||
"addr": 1, "value": 9},
|
||||
|
||||
{"_quirk": "Y-output bank marker cell. pymodbus's simulator maps Modbus FC01/02/05 bit-addresses to cell index = bit_addr / 16; so Modbus coil 2048 lives at cell 128 bit 0. Y0=ON (bit 0), Y1=OFF (bit 1), Y2=ON (bit 2) -> value=0b00000101=5 proves DL260 mapping Y0 -> coil 2048.",
|
||||
"addr": 128, "value": 5},
|
||||
|
||||
{"_quirk": "C-relay bank marker cell. Modbus coil 3072 -> cell 192 bit 0. C0=ON (bit 0), C1=OFF (bit 1), C2=ON (bit 2) -> value=5 proves DL260 mapping C0 -> coil 3072.",
|
||||
"addr": 192, "value": 5},
|
||||
|
||||
{"_quirk": "Scratch cell for coil 4000..4015 write round-trip tests. Cell 250 holds Modbus coils 4000-4015; all bits start at 0 and tests set specific bits via FC05.",
|
||||
"addr": 250, "value": 0}
|
||||
],
|
||||
|
||||
"uint32": [],
|
||||
"float32": [],
|
||||
"string": [],
|
||||
"repeat": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Launches the pymodbus simulator with one of the integration-test profiles
|
||||
(Standard or DL205). Foreground process — Ctrl+C to stop.
|
||||
|
||||
.PARAMETER Profile
|
||||
Which simulator profile to run: 'standard' or 'dl205'. Both bind TCP 5020 by
|
||||
default so they can't run simultaneously on the same box.
|
||||
|
||||
.PARAMETER HttpPort
|
||||
Port for pymodbus's optional web UI / REST API. Default 8080. Pass 0 to
|
||||
disable (passes --no_http).
|
||||
|
||||
.EXAMPLE
|
||||
.\serve.ps1 -Profile standard
|
||||
Starts the standard server on TCP 5020 with web UI on 8080.
|
||||
|
||||
.EXAMPLE
|
||||
.\serve.ps1 -Profile dl205 -HttpPort 0
|
||||
Starts the DL205 server on TCP 5020, no web UI.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [ValidateSet('standard', 'dl205')] [string]$Profile,
|
||||
[int]$HttpPort = 8080
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$here = $PSScriptRoot
|
||||
|
||||
# Confirm pymodbus.simulator is on PATH — clearer message than the
|
||||
# 'CommandNotFoundException' dotnet style.
|
||||
$cmd = Get-Command pymodbus.simulator -ErrorAction SilentlyContinue
|
||||
if (-not $cmd) {
|
||||
Write-Error "pymodbus.simulator not found. Install with: pip install 'pymodbus[simulator]==3.13.0'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$jsonFile = Join-Path $here "$Profile.json"
|
||||
if (-not (Test-Path $jsonFile)) {
|
||||
Write-Error "Profile config not found: $jsonFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$args = @(
|
||||
'--modbus_server', 'srv',
|
||||
'--modbus_device', 'dev',
|
||||
'--json_file', $jsonFile
|
||||
)
|
||||
|
||||
if ($HttpPort -gt 0) {
|
||||
$args += @('--http_port', $HttpPort)
|
||||
Write-Host "Web UI will be at http://localhost:$HttpPort"
|
||||
} else {
|
||||
$args += '--no_http'
|
||||
}
|
||||
|
||||
Write-Host "Starting pymodbus simulator: profile=$Profile TCP=localhost:5020"
|
||||
Write-Host "Ctrl+C to stop."
|
||||
& pymodbus.simulator @args
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"_comment": "Standard.json — generic Modbus TCP server for the integration suite. See ../README.md. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live in the README + git history. Layout: HR[0..31]=address-as-value, HR[100]=auto-increment, HR[200..209]=scratch, coils 1024..1055=alternating, coils 1100..1109=scratch. Coils live at 1024+ because pymodbus stores all 4 standard tables in ONE underlying cell array — bits and uint16 at the same address conflict (each cell can only be typed once).",
|
||||
|
||||
"server_list": {
|
||||
"srv": {
|
||||
"comm": "tcp",
|
||||
"host": "0.0.0.0",
|
||||
"port": 5020,
|
||||
"framer": "socket",
|
||||
"device_id": 1
|
||||
}
|
||||
},
|
||||
|
||||
"device_list": {
|
||||
"dev": {
|
||||
"setup": {
|
||||
"co size": 2048,
|
||||
"di size": 2048,
|
||||
"hr size": 2048,
|
||||
"ir size": 2048,
|
||||
"shared blocks": true,
|
||||
"type exception": false,
|
||||
"defaults": {
|
||||
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
|
||||
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
|
||||
}
|
||||
},
|
||||
"invalid": [],
|
||||
"write": [
|
||||
[0, 31],
|
||||
[100, 100],
|
||||
[200, 209],
|
||||
[1024, 1055],
|
||||
[1100, 1109]
|
||||
],
|
||||
|
||||
"uint16": [
|
||||
{"addr": 0, "value": 0}, {"addr": 1, "value": 1},
|
||||
{"addr": 2, "value": 2}, {"addr": 3, "value": 3},
|
||||
{"addr": 4, "value": 4}, {"addr": 5, "value": 5},
|
||||
{"addr": 6, "value": 6}, {"addr": 7, "value": 7},
|
||||
{"addr": 8, "value": 8}, {"addr": 9, "value": 9},
|
||||
{"addr": 10, "value": 10}, {"addr": 11, "value": 11},
|
||||
{"addr": 12, "value": 12}, {"addr": 13, "value": 13},
|
||||
{"addr": 14, "value": 14}, {"addr": 15, "value": 15},
|
||||
{"addr": 16, "value": 16}, {"addr": 17, "value": 17},
|
||||
{"addr": 18, "value": 18}, {"addr": 19, "value": 19},
|
||||
{"addr": 20, "value": 20}, {"addr": 21, "value": 21},
|
||||
{"addr": 22, "value": 22}, {"addr": 23, "value": 23},
|
||||
{"addr": 24, "value": 24}, {"addr": 25, "value": 25},
|
||||
{"addr": 26, "value": 26}, {"addr": 27, "value": 27},
|
||||
{"addr": 28, "value": 28}, {"addr": 29, "value": 29},
|
||||
{"addr": 30, "value": 30}, {"addr": 31, "value": 31},
|
||||
|
||||
{"addr": 100, "value": 0,
|
||||
"action": "increment",
|
||||
"parameters": {"minval": 0, "maxval": 65535}},
|
||||
|
||||
{"addr": 200, "value": 0}, {"addr": 201, "value": 0},
|
||||
{"addr": 202, "value": 0}, {"addr": 203, "value": 0},
|
||||
{"addr": 204, "value": 0}, {"addr": 205, "value": 0},
|
||||
{"addr": 206, "value": 0}, {"addr": 207, "value": 0},
|
||||
{"addr": 208, "value": 0}, {"addr": 209, "value": 0}
|
||||
],
|
||||
|
||||
"bits": [
|
||||
{"addr": 1024, "value": 1}, {"addr": 1025, "value": 0},
|
||||
{"addr": 1026, "value": 1}, {"addr": 1027, "value": 0},
|
||||
{"addr": 1028, "value": 1}, {"addr": 1029, "value": 0},
|
||||
{"addr": 1030, "value": 1}, {"addr": 1031, "value": 0},
|
||||
{"addr": 1032, "value": 1}, {"addr": 1033, "value": 0},
|
||||
{"addr": 1034, "value": 1}, {"addr": 1035, "value": 0},
|
||||
{"addr": 1036, "value": 1}, {"addr": 1037, "value": 0},
|
||||
{"addr": 1038, "value": 1}, {"addr": 1039, "value": 0},
|
||||
{"addr": 1040, "value": 1}, {"addr": 1041, "value": 0},
|
||||
{"addr": 1042, "value": 1}, {"addr": 1043, "value": 0},
|
||||
{"addr": 1044, "value": 1}, {"addr": 1045, "value": 0},
|
||||
{"addr": 1046, "value": 1}, {"addr": 1047, "value": 0},
|
||||
{"addr": 1048, "value": 1}, {"addr": 1049, "value": 0},
|
||||
{"addr": 1050, "value": 1}, {"addr": 1051, "value": 0},
|
||||
{"addr": 1052, "value": 1}, {"addr": 1053, "value": 0},
|
||||
{"addr": 1054, "value": 1}, {"addr": 1055, "value": 0},
|
||||
|
||||
{"addr": 1100, "value": 0}, {"addr": 1101, "value": 0},
|
||||
{"addr": 1102, "value": 0}, {"addr": 1103, "value": 0},
|
||||
{"addr": 1104, "value": 0}, {"addr": 1105, "value": 0},
|
||||
{"addr": 1106, "value": 0}, {"addr": 1107, "value": 0},
|
||||
{"addr": 1108, "value": 0}, {"addr": 1109, "value": 0}
|
||||
],
|
||||
|
||||
"uint32": [],
|
||||
"float32": [],
|
||||
"string": [],
|
||||
"repeat": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Pymodbus\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<None Update="DL205\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,139 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DirectLogicAddressTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("V0", (ushort)0x0000)]
|
||||
[InlineData("V1", (ushort)0x0001)]
|
||||
[InlineData("V7", (ushort)0x0007)]
|
||||
[InlineData("V10", (ushort)0x0008)]
|
||||
[InlineData("V2000", (ushort)0x0400)] // canonical DL205/DL260 user-memory start
|
||||
[InlineData("V7777", (ushort)0x0FFF)]
|
||||
[InlineData("V10000", (ushort)0x1000)]
|
||||
[InlineData("V17777", (ushort)0x1FFF)]
|
||||
public void UserVMemoryToPdu_converts_octal_V_prefix(string v, ushort expected)
|
||||
=> DirectLogicAddress.UserVMemoryToPdu(v).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("0", (ushort)0)]
|
||||
[InlineData("2000", (ushort)0x0400)]
|
||||
[InlineData("v2000", (ushort)0x0400)] // lowercase v
|
||||
[InlineData(" V2000 ", (ushort)0x0400)] // surrounding whitespace
|
||||
public void UserVMemoryToPdu_accepts_bare_or_prefixed_or_padded(string v, ushort expected)
|
||||
=> DirectLogicAddress.UserVMemoryToPdu(v).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("V8")] // 8 is not a valid octal digit
|
||||
[InlineData("V19")]
|
||||
[InlineData("V2009")]
|
||||
public void UserVMemoryToPdu_rejects_non_octal_digits(string v)
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => DirectLogicAddress.UserVMemoryToPdu(v))
|
||||
.Message.ShouldContain("octal");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("V")]
|
||||
public void UserVMemoryToPdu_rejects_empty_input(string? v)
|
||||
=> Should.Throw<ArgumentException>(() => DirectLogicAddress.UserVMemoryToPdu(v!));
|
||||
|
||||
[Fact]
|
||||
public void UserVMemoryToPdu_overflow_rejected()
|
||||
{
|
||||
// 200000 octal = 0x10000 — one past ushort range.
|
||||
Should.Throw<OverflowException>(() => DirectLogicAddress.UserVMemoryToPdu("V200000"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemVMemoryBasePdu_is_0x2100_for_V40400()
|
||||
{
|
||||
// V40400 on DL260 / H2-ECOM100 absolute mode → PDU 0x2100 (decimal 8448), NOT 0x4100
|
||||
// which a naive octal-to-decimal of 40400 octal would give (= 16640).
|
||||
DirectLogicAddress.SystemVMemoryBasePdu.ShouldBe((ushort)0x2100);
|
||||
DirectLogicAddress.SystemVMemoryToPdu(0).ShouldBe((ushort)0x2100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemVMemoryToPdu_offsets_within_bank()
|
||||
{
|
||||
DirectLogicAddress.SystemVMemoryToPdu(1).ShouldBe((ushort)0x2101);
|
||||
DirectLogicAddress.SystemVMemoryToPdu(0x100).ShouldBe((ushort)0x2200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemVMemoryToPdu_rejects_overflow()
|
||||
{
|
||||
// ushort wrap: 0xFFFF - 0x2100 = 0xDEFF; anything above should throw.
|
||||
Should.NotThrow(() => DirectLogicAddress.SystemVMemoryToPdu(0xDEFF));
|
||||
Should.Throw<OverflowException>(() => DirectLogicAddress.SystemVMemoryToPdu(0xDF00));
|
||||
}
|
||||
|
||||
// --- Bit memory: Y-output, C-relay, X-input, SP-special ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("Y0", (ushort)2048)]
|
||||
[InlineData("Y1", (ushort)2049)]
|
||||
[InlineData("Y7", (ushort)2055)]
|
||||
[InlineData("Y10", (ushort)2056)] // octal 10 = decimal 8
|
||||
[InlineData("Y17", (ushort)2063)] // octal 17 = decimal 15
|
||||
[InlineData("Y777", (ushort)2559)] // top of DL260 Y range per doc table
|
||||
public void YOutputToCoil_adds_octal_offset_to_2048(string y, ushort expected)
|
||||
=> DirectLogicAddress.YOutputToCoil(y).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("C0", (ushort)3072)]
|
||||
[InlineData("C1", (ushort)3073)]
|
||||
[InlineData("C10", (ushort)3080)]
|
||||
[InlineData("C1777", (ushort)4095)] // top of DL260 C range
|
||||
public void CRelayToCoil_adds_octal_offset_to_3072(string c, ushort expected)
|
||||
=> DirectLogicAddress.CRelayToCoil(c).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("X0", (ushort)0)]
|
||||
[InlineData("X17", (ushort)15)]
|
||||
[InlineData("X777", (ushort)511)] // top of DL260 X range
|
||||
public void XInputToDiscrete_adds_octal_offset_to_0(string x, ushort expected)
|
||||
=> DirectLogicAddress.XInputToDiscrete(x).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("SP0", (ushort)1024)]
|
||||
[InlineData("SP7", (ushort)1031)]
|
||||
[InlineData("sp0", (ushort)1024)] // lowercase prefix
|
||||
[InlineData("SP777", (ushort)1535)]
|
||||
public void SpecialToDiscrete_adds_octal_offset_to_1024(string sp, ushort expected)
|
||||
=> DirectLogicAddress.SpecialToDiscrete(sp).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("Y8")]
|
||||
[InlineData("C9")]
|
||||
[InlineData("X18")]
|
||||
public void Bit_address_rejects_non_octal_digits(string bad)
|
||||
=> Should.Throw<ArgumentException>(() =>
|
||||
{
|
||||
if (bad[0] == 'Y') DirectLogicAddress.YOutputToCoil(bad);
|
||||
else if (bad[0] == 'C') DirectLogicAddress.CRelayToCoil(bad);
|
||||
else DirectLogicAddress.XInputToDiscrete(bad);
|
||||
});
|
||||
|
||||
[Theory]
|
||||
[InlineData("Y")]
|
||||
[InlineData("C")]
|
||||
[InlineData("")]
|
||||
public void Bit_address_rejects_empty(string bad)
|
||||
=> Should.Throw<ArgumentException>(() => DirectLogicAddress.YOutputToCoil(bad));
|
||||
|
||||
[Fact]
|
||||
public void YOutputToCoil_accepts_lowercase_prefix()
|
||||
=> DirectLogicAddress.YOutputToCoil("y0").ShouldBe((ushort)2048);
|
||||
|
||||
[Fact]
|
||||
public void CRelayToCoil_accepts_bare_octal_without_C_prefix()
|
||||
=> DirectLogicAddress.CRelayToCoil("0").ShouldBe((ushort)3072);
|
||||
}
|
||||
165
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCapTests.cs
Normal file
165
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCapTests.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusCapTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Records every PDU sent so tests can assert request-count and per-request quantity —
|
||||
/// the only observable behaviour of the auto-chunking path.
|
||||
/// </summary>
|
||||
private sealed class RecordingTransport : IModbusTransport
|
||||
{
|
||||
public readonly ushort[] HoldingRegisters = new ushort[1024];
|
||||
public readonly List<(ushort Address, ushort Quantity)> Fc03Requests = new();
|
||||
public readonly List<(ushort Address, ushort Quantity)> Fc16Requests = new();
|
||||
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var fc = pdu[0];
|
||||
if (fc == 0x03)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
Fc03Requests.Add((addr, qty));
|
||||
var byteCount = (byte)(qty * 2);
|
||||
var resp = new byte[2 + byteCount];
|
||||
resp[0] = 0x03;
|
||||
resp[1] = byteCount;
|
||||
for (var i = 0; i < qty; i++)
|
||||
{
|
||||
resp[2 + i * 2] = (byte)(HoldingRegisters[addr + i] >> 8);
|
||||
resp[3 + i * 2] = (byte)(HoldingRegisters[addr + i] & 0xFF);
|
||||
}
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
if (fc == 0x10)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
Fc16Requests.Add((addr, qty));
|
||||
for (var i = 0; i < qty; i++)
|
||||
HoldingRegisters[addr + i] = (ushort)((pdu[6 + i * 2] << 8) | pdu[7 + i * 2]);
|
||||
return Task.FromResult(new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] });
|
||||
}
|
||||
return Task.FromException<byte[]>(new ModbusException(fc, 0x01, $"fc={fc} unsupported"));
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_within_cap_issues_single_FC03_request()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("S", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 40); // 20 regs — fits in default cap (125).
|
||||
var transport = new RecordingTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
_ = await drv.ReadAsync(["S"], TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Fc03Requests.Count.ShouldBe(1);
|
||||
transport.Fc03Requests[0].Quantity.ShouldBe((ushort)20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_above_cap_splits_into_two_FC03_requests()
|
||||
{
|
||||
// 240-char string = 120 regs. Cap = 100 (a typical sub-spec device cap). Expect 100 + 20.
|
||||
var tag = new ModbusTagDefinition("LongString", ModbusRegion.HoldingRegisters, 100, ModbusDataType.String,
|
||||
StringLength: 240);
|
||||
var transport = new RecordingTransport();
|
||||
// Seed cells so the re-assembled payload is stable — confirms chunks are stitched in order.
|
||||
for (ushort i = 100; i < 100 + 120; i++)
|
||||
transport.HoldingRegisters[i] = (ushort)((('A' + (i - 100) % 26) << 8) | ('A' + (i - 100) % 26));
|
||||
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "fake",
|
||||
Tags = [tag],
|
||||
MaxRegistersPerRead = 100,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.ReadAsync(["LongString"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
transport.Fc03Requests.Count.ShouldBe(2, "120 regs / cap 100 → 2 requests");
|
||||
transport.Fc03Requests[0].ShouldBe(((ushort)100, (ushort)100));
|
||||
transport.Fc03Requests[1].ShouldBe(((ushort)200, (ushort)20));
|
||||
|
||||
// Payload continuity: re-assembled string starts where register 100 does and keeps going.
|
||||
var s = (string)results[0].Value!;
|
||||
s.Length.ShouldBeGreaterThan(0);
|
||||
s[0].ShouldBe('A'); // register[100] high byte
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_cap_honors_Mitsubishi_lower_cap_of_64()
|
||||
{
|
||||
// 200-char string = 100 regs. Mitsubishi Q cap = 64. Expect: 64, 36.
|
||||
var tag = new ModbusTagDefinition("MitString", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 200);
|
||||
var transport = new RecordingTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerRead = 64, Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
_ = await drv.ReadAsync(["MitString"], TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Fc03Requests.Count.ShouldBe(2);
|
||||
transport.Fc03Requests[0].Quantity.ShouldBe((ushort)64);
|
||||
transport.Fc03Requests[1].Quantity.ShouldBe((ushort)36);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_exceeding_cap_throws_instead_of_splitting()
|
||||
{
|
||||
// Partial FC16 across two transactions is not atomic. Forcing an explicit exception so the
|
||||
// caller knows their tag definition is incompatible with the device cap rather than silently
|
||||
// writing half a string and crashing between chunks.
|
||||
var tag = new ModbusTagDefinition("LongStringWrite", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 220); // 110 regs.
|
||||
var transport = new RecordingTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerWrite = 100, Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("LongStringWrite", new string('A', 220))],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Driver catches the internal exception and surfaces BadInternalError — the Fc16Requests
|
||||
// list must still be empty because nothing was sent.
|
||||
results[0].StatusCode.ShouldNotBe(0u);
|
||||
transport.Fc16Requests.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_within_cap_proceeds_normally()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("ShortStringWrite", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 40); // 20 regs.
|
||||
var transport = new RecordingTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerWrite = 100, Probe = new ModbusProbeOptions { Enabled = false } };
|
||||
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("ShortStringWrite", "HELLO")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
transport.Fc16Requests.Count.ShouldBe(1);
|
||||
transport.Fc16Requests[0].Quantity.ShouldBe((ushort)20);
|
||||
}
|
||||
}
|
||||
@@ -172,4 +172,144 @@ public sealed class ModbusDataTypeTests
|
||||
wire[1].ShouldBe((byte)'i');
|
||||
for (var i = 2; i < 8; i++) wire[i].ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
// --- DL205 low-byte-first strings (AutomationDirect DirectLOGIC quirk) ---
|
||||
|
||||
[Fact]
|
||||
public void String_LowByteFirst_decodes_DL205_packed_Hello()
|
||||
{
|
||||
// HR[1040] = 0x6548 (wire BE bytes [0x65, 0x48]) decodes first char from low byte = 'H',
|
||||
// second from high byte = 'e'. HR[1041] = 0x6C6C → 'l','l'. HR[1042] = 0x006F → 'o', nul.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 5, StringByteOrder: ModbusStringByteOrder.LowByteFirst);
|
||||
var wire = new byte[] { 0x65, 0x48, 0x6C, 0x6C, 0x00, 0x6F };
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe("Hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void String_LowByteFirst_decode_truncates_at_first_nul()
|
||||
{
|
||||
// Low-byte-first with only 2 real chars in register 0 (lo='H', hi='i') and the rest nul.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 6, StringByteOrder: ModbusStringByteOrder.LowByteFirst);
|
||||
var wire = new byte[] { 0x69, 0x48, 0x00, 0x00, 0x00, 0x00 };
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe("Hi");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void String_LowByteFirst_encode_round_trips_with_decode()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 5, StringByteOrder: ModbusStringByteOrder.LowByteFirst);
|
||||
var wire = ModbusDriver.EncodeRegister("Hello", tag);
|
||||
// Expect exactly the DL205-documented byte sequence.
|
||||
wire.ShouldBe(new byte[] { 0x65, 0x48, 0x6C, 0x6C, 0x00, 0x6F });
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe("Hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void String_HighByteFirst_and_LowByteFirst_differ_on_same_wire()
|
||||
{
|
||||
// Same wire buffer, different byte order → first char switches 'H' vs 'e'.
|
||||
var wire = new byte[] { 0x48, 0x65 };
|
||||
var hi = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 2, StringByteOrder: ModbusStringByteOrder.HighByteFirst);
|
||||
var lo = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 2, StringByteOrder: ModbusStringByteOrder.LowByteFirst);
|
||||
ModbusDriver.DecodeRegister(wire, hi).ShouldBe("He");
|
||||
ModbusDriver.DecodeRegister(wire, lo).ShouldBe("eH");
|
||||
}
|
||||
|
||||
// --- BCD (binary-coded decimal, DL205/DL260 default numeric encoding) ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x0000u, 0u)]
|
||||
[InlineData(0x0001u, 1u)]
|
||||
[InlineData(0x0009u, 9u)]
|
||||
[InlineData(0x0010u, 10u)]
|
||||
[InlineData(0x1234u, 1234u)]
|
||||
[InlineData(0x9999u, 9999u)]
|
||||
public void DecodeBcd_16_bit_decodes_expected_decimal(uint raw, uint expected)
|
||||
=> ModbusDriver.DecodeBcd(raw, nibbles: 4).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void DecodeBcd_rejects_nibbles_above_nine()
|
||||
{
|
||||
Should.Throw<InvalidDataException>(() => ModbusDriver.DecodeBcd(0x00A5u, nibbles: 4))
|
||||
.Message.ShouldContain("Non-BCD nibble");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u, 0x0000u)]
|
||||
[InlineData(5u, 0x0005u)]
|
||||
[InlineData(42u, 0x0042u)]
|
||||
[InlineData(1234u, 0x1234u)]
|
||||
[InlineData(9999u, 0x9999u)]
|
||||
public void EncodeBcd_16_bit_encodes_expected_nibbles(uint value, uint expected)
|
||||
=> ModbusDriver.EncodeBcd(value, nibbles: 4).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Bcd16_decodes_DL205_register_1234_as_decimal_1234()
|
||||
{
|
||||
// HR[1072] = 0x1234 on the DL205 profile represents decimal 1234. A plain Int16 decode
|
||||
// would return 0x04D2 = 4660 — proof the BCD path is different.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd16);
|
||||
ModbusDriver.DecodeRegister(new byte[] { 0x12, 0x34 }, tag).ShouldBe(1234);
|
||||
|
||||
var int16Tag = tag with { DataType = ModbusDataType.Int16 };
|
||||
ModbusDriver.DecodeRegister(new byte[] { 0x12, 0x34 }, int16Tag).ShouldBe((short)0x1234);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bcd16_encode_round_trips_with_decode()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd16);
|
||||
var wire = ModbusDriver.EncodeRegister(4321, tag);
|
||||
wire.ShouldBe(new byte[] { 0x43, 0x21 });
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(4321);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bcd16_encode_rejects_out_of_range_values()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd16);
|
||||
Should.Throw<OverflowException>(() => ModbusDriver.EncodeRegister(10000, tag))
|
||||
.Message.ShouldContain("4 decimal digits");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bcd32_decodes_8_digits_big_endian()
|
||||
{
|
||||
// 0x12345678 as BCD = decimal 12_345_678.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd32);
|
||||
ModbusDriver.DecodeRegister(new byte[] { 0x12, 0x34, 0x56, 0x78 }, tag).ShouldBe(12_345_678);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bcd32_word_swap_handles_CDAB_layout()
|
||||
{
|
||||
// PLC stored 12_345_678 with word swap: low-word 0x5678 first, high-word 0x1234 second.
|
||||
// Wire bytes [0x56, 0x78, 0x12, 0x34] + WordSwap → decode to decimal 12_345_678.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd32,
|
||||
ByteOrder: ModbusByteOrder.WordSwap);
|
||||
ModbusDriver.DecodeRegister(new byte[] { 0x56, 0x78, 0x12, 0x34 }, tag).ShouldBe(12_345_678);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bcd32_encode_round_trips_with_decode()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd32);
|
||||
var wire = ModbusDriver.EncodeRegister(87_654_321u, tag);
|
||||
wire.ShouldBe(new byte[] { 0x87, 0x65, 0x43, 0x21 });
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(87_654_321);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bcd_RegisterCount_matches_underlying_width()
|
||||
{
|
||||
var b16 = new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd16);
|
||||
var b32 = new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Bcd32);
|
||||
ModbusDriver.RegisterCount(b16).ShouldBe((ushort)1);
|
||||
ModbusDriver.RegisterCount(b32).ShouldBe((ushort)2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Linq;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit coverage for the static helpers <see cref="DriverNodeManager"/> exposes to bridge
|
||||
/// driver-side history data (<see cref="HistoricalEvent"/> + <see cref="DataValueSnapshot"/>)
|
||||
/// to the OPC UA on-wire shape (<c>HistoryData</c> / <c>HistoryEvent</c> wrapped in an
|
||||
/// <see cref="ExtensionObject"/>). Fast, framework-only — no server fixture.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverNodeManagerHistoryMappingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(nameof(HistoryAggregateType.Average), HistoryAggregateType.Average)]
|
||||
[InlineData(nameof(HistoryAggregateType.Minimum), HistoryAggregateType.Minimum)]
|
||||
[InlineData(nameof(HistoryAggregateType.Maximum), HistoryAggregateType.Maximum)]
|
||||
[InlineData(nameof(HistoryAggregateType.Total), HistoryAggregateType.Total)]
|
||||
[InlineData(nameof(HistoryAggregateType.Count), HistoryAggregateType.Count)]
|
||||
public void MapAggregate_translates_each_supported_OPC_UA_aggregate_NodeId(
|
||||
string name, HistoryAggregateType expected)
|
||||
{
|
||||
// Resolve the ObjectIds.AggregateFunction_<name> constant via reflection so the test
|
||||
// keeps working if the stack ever renames them — failure means the stack broke its
|
||||
// naming convention, worth surfacing loudly.
|
||||
var field = typeof(ObjectIds).GetField("AggregateFunction_" + name);
|
||||
field.ShouldNotBeNull();
|
||||
var nodeId = (NodeId)field!.GetValue(null)!;
|
||||
|
||||
DriverNodeManager.MapAggregate(nodeId).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAggregate_returns_null_for_unknown_aggregate()
|
||||
{
|
||||
// AggregateFunction_TimeAverage is a valid OPC UA aggregate but not one the driver
|
||||
// surfaces. Null here means the service handler will translate to BadAggregateNotSupported
|
||||
// — the right behavior per Part 13 when the requested aggregate isn't implemented.
|
||||
DriverNodeManager.MapAggregate(ObjectIds.AggregateFunction_TimeAverage).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAggregate_returns_null_for_null_input()
|
||||
{
|
||||
// Processed requests that omit the aggregate list (or pass a single null) must not crash.
|
||||
DriverNodeManager.MapAggregate(null).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHistoryData_wraps_samples_as_HistoryData_extension_object()
|
||||
{
|
||||
var samples = new[]
|
||||
{
|
||||
new DataValueSnapshot(Value: 42, StatusCode: StatusCodes.Good,
|
||||
SourceTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
ServerTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 1, DateTimeKind.Utc)),
|
||||
new DataValueSnapshot(Value: 99, StatusCode: StatusCodes.Good,
|
||||
SourceTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 5, DateTimeKind.Utc),
|
||||
ServerTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 6, DateTimeKind.Utc)),
|
||||
};
|
||||
|
||||
var ext = DriverNodeManager.BuildHistoryData(samples);
|
||||
|
||||
ext.Body.ShouldBeOfType<HistoryData>();
|
||||
var hd = (HistoryData)ext.Body;
|
||||
hd.DataValues.Count.ShouldBe(2);
|
||||
hd.DataValues[0].Value.ShouldBe(42);
|
||||
hd.DataValues[1].Value.ShouldBe(99);
|
||||
hd.DataValues[0].SourceTimestamp.ShouldBe(new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHistoryEvent_wraps_events_with_BaseEventType_field_ordering()
|
||||
{
|
||||
// BuildHistoryEvent populates a fixed field set in BaseEventType's conventional order:
|
||||
// EventId, SourceName, Message, Severity, Time, ReceiveTime. Pinning this so a later
|
||||
// "respect the client's SelectClauses" change can't silently break older clients that
|
||||
// rely on the default layout.
|
||||
var events = new[]
|
||||
{
|
||||
new HistoricalEvent(
|
||||
EventId: "e-1",
|
||||
SourceName: "Tank1.HiAlarm",
|
||||
EventTimeUtc: new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc),
|
||||
ReceivedTimeUtc: new DateTime(2024, 1, 1, 12, 0, 0, 5, DateTimeKind.Utc),
|
||||
Message: "High level reached",
|
||||
Severity: 750),
|
||||
};
|
||||
|
||||
var ext = DriverNodeManager.BuildHistoryEvent(events);
|
||||
|
||||
ext.Body.ShouldBeOfType<HistoryEvent>();
|
||||
var he = (HistoryEvent)ext.Body;
|
||||
he.Events.Count.ShouldBe(1);
|
||||
var fields = he.Events[0].EventFields;
|
||||
fields.Count.ShouldBe(6);
|
||||
fields[0].Value.ShouldBe("e-1"); // EventId
|
||||
fields[1].Value.ShouldBe("Tank1.HiAlarm"); // SourceName
|
||||
((LocalizedText)fields[2].Value).Text.ShouldBe("High level reached"); // Message
|
||||
fields[3].Value.ShouldBe((ushort)750); // Severity
|
||||
((DateTime)fields[4].Value).ShouldBe(new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc));
|
||||
((DateTime)fields[5].Value).ShouldBe(new DateTime(2024, 1, 1, 12, 0, 0, 5, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHistoryEvent_substitutes_empty_string_for_null_SourceName_and_Message()
|
||||
{
|
||||
// Driver-side nulls are preserved through the wire contract by design (distinguishes
|
||||
// "system event with no source" from "source unknown"), but OPC UA Variants of type
|
||||
// String must not carry null — the stack serializes null-string as empty. This test
|
||||
// pins the choice so a nullable-Variant refactor doesn't break clients that display
|
||||
// the field without a null check.
|
||||
var events = new[]
|
||||
{
|
||||
new HistoricalEvent("sys", null, DateTime.UtcNow, DateTime.UtcNow, null, 1),
|
||||
};
|
||||
|
||||
var ext = DriverNodeManager.BuildHistoryEvent(events);
|
||||
var fields = ((HistoryEvent)ext.Body).Events[0].EventFields;
|
||||
fields[1].Value.ShouldBe(string.Empty);
|
||||
((LocalizedText)fields[2].Value).Text.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDataValue_preserves_status_code_and_timestamps()
|
||||
{
|
||||
var snap = new DataValueSnapshot(
|
||||
Value: 123.45,
|
||||
StatusCode: StatusCodes.UncertainSubstituteValue,
|
||||
SourceTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 0, DateTimeKind.Utc),
|
||||
ServerTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc));
|
||||
|
||||
var dv = DriverNodeManager.ToDataValue(snap);
|
||||
|
||||
dv.Value.ShouldBe(123.45);
|
||||
dv.StatusCode.Code.ShouldBe(StatusCodes.UncertainSubstituteValue);
|
||||
dv.SourceTimestamp.ShouldBe(new DateTime(2024, 5, 1, 10, 0, 0, DateTimeKind.Utc));
|
||||
dv.ServerTimestamp.ShouldBe(new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDataValue_leaves_SourceTimestamp_default_when_snapshot_has_no_source_time()
|
||||
{
|
||||
// Galaxy's raw-history rows often carry only a ServerTimestamp (the historian knows
|
||||
// when it wrote the row, not when the process sampled it). The mapping must not
|
||||
// synthesize a bogus SourceTimestamp from ServerTimestamp — that would lie to the
|
||||
// client about the measurement's actual time.
|
||||
var snap = new DataValueSnapshot(Value: 1, StatusCode: 0,
|
||||
SourceTimestampUtc: null,
|
||||
ServerTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc));
|
||||
|
||||
var dv = DriverNodeManager.ToDataValue(snap);
|
||||
dv.SourceTimestamp.ShouldBe(default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
// Core.Abstractions.HistoryReadResult (driver-side samples) collides with Opc.Ua.HistoryReadResult
|
||||
// (service-layer per-node result). Alias the driver type so the stub's interface implementations
|
||||
// are unambiguous.
|
||||
using DriverHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end test that a real OPC UA client's HistoryRead service reaches a fake driver's
|
||||
/// <see cref="IHistoryProvider"/> via <see cref="DriverNodeManager"/>'s
|
||||
/// <c>HistoryReadRawModified</c> / <c>HistoryReadProcessed</c> / <c>HistoryReadAtTime</c> /
|
||||
/// <c>HistoryReadEvents</c> overrides. Boots the full OPC UA stack + a stub
|
||||
/// <see cref="IHistoryProvider"/> driver, opens a client session, issues each HistoryRead
|
||||
/// variant, and asserts the client receives the expected per-kind payload.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class HistoryReadIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly int Port = 48600 + Random.Shared.Next(0, 99);
|
||||
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaHistoryTest";
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-history-test-{Guid.NewGuid():N}");
|
||||
|
||||
private DriverHost _driverHost = null!;
|
||||
private OpcUaApplicationHost _server = null!;
|
||||
private HistoryDriver _driver = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_driverHost = new DriverHost();
|
||||
_driver = new HistoryDriver();
|
||||
await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None);
|
||||
|
||||
var options = new OpcUaServerOptions
|
||||
{
|
||||
EndpointUrl = _endpoint,
|
||||
ApplicationName = "OtOpcUaHistoryTest",
|
||||
ApplicationUri = "urn:OtOpcUa:Server:HistoryTest",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
};
|
||||
|
||||
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
|
||||
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
|
||||
await _server.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
await _driverHost.DisposeAsync();
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadRaw_round_trips_driver_samples_to_the_client()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
||||
var nodeId = new NodeId("raw.var", nsIndex);
|
||||
|
||||
// The Opc.Ua client exposes HistoryRead via Session.HistoryRead. We construct a
|
||||
// ReadRawModifiedDetails (IsReadModified=false → raw path) and a single
|
||||
// HistoryReadValueId targeting the driver-backed variable.
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
EndTime = new DateTime(2024, 1, 1, 0, 0, 10, DateTimeKind.Utc),
|
||||
NumValuesPerNode = 100,
|
||||
IsReadModified = false,
|
||||
ReturnBounds = false,
|
||||
};
|
||||
var extObj = new ExtensionObject(details);
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
||||
|
||||
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
||||
out var results, out _);
|
||||
|
||||
results.Count.ShouldBe(1);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good, $"HistoryReadRaw returned {results[0].StatusCode}");
|
||||
var hd = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||
hd.DataValues.Count.ShouldBe(_driver.RawSamplesReturned, "one DataValue per driver sample");
|
||||
hd.DataValues[0].Value.ShouldBe(_driver.FirstRawValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadProcessed_maps_Average_aggregate_and_routes_to_ReadProcessedAsync()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
||||
var nodeId = new NodeId("proc.var", nsIndex);
|
||||
|
||||
var details = new ReadProcessedDetails
|
||||
{
|
||||
StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
EndTime = new DateTime(2024, 1, 1, 0, 1, 0, DateTimeKind.Utc),
|
||||
ProcessingInterval = 10_000, // 10s buckets
|
||||
AggregateType = [ObjectIds.AggregateFunction_Average],
|
||||
};
|
||||
var extObj = new ExtensionObject(details);
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
||||
|
||||
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
||||
out var results, out _);
|
||||
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
_driver.LastProcessedAggregate.ShouldBe(HistoryAggregateType.Average,
|
||||
"MapAggregate must translate ObjectIds.AggregateFunction_Average → driver enum");
|
||||
_driver.LastProcessedInterval.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadProcessed_returns_BadAggregateNotSupported_for_unmapped_aggregate()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
||||
var nodeId = new NodeId("proc.var", nsIndex);
|
||||
|
||||
var details = new ReadProcessedDetails
|
||||
{
|
||||
StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
EndTime = new DateTime(2024, 1, 1, 0, 1, 0, DateTimeKind.Utc),
|
||||
ProcessingInterval = 10_000,
|
||||
// TimeAverage is a valid OPC UA aggregate NodeId but not one the driver implements —
|
||||
// the override returns BadAggregateNotSupported per Part 13 rather than coercing.
|
||||
AggregateType = [ObjectIds.AggregateFunction_TimeAverage],
|
||||
};
|
||||
var extObj = new ExtensionObject(details);
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
||||
|
||||
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
||||
out var results, out _);
|
||||
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadAtTime_forwards_timestamp_list_to_driver()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
||||
var nodeId = new NodeId("atTime.var", nsIndex);
|
||||
|
||||
var t1 = new DateTime(2024, 3, 1, 10, 0, 0, DateTimeKind.Utc);
|
||||
var t2 = new DateTime(2024, 3, 1, 10, 0, 30, DateTimeKind.Utc);
|
||||
var details = new ReadAtTimeDetails { ReqTimes = new DateTimeCollection { t1, t2 } };
|
||||
var extObj = new ExtensionObject(details);
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
||||
|
||||
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
||||
out var results, out _);
|
||||
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
_driver.LastAtTimeRequestedTimes.ShouldNotBeNull();
|
||||
_driver.LastAtTimeRequestedTimes!.Count.ShouldBe(2);
|
||||
_driver.LastAtTimeRequestedTimes[0].ShouldBe(t1);
|
||||
_driver.LastAtTimeRequestedTimes[1].ShouldBe(t2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadEvents_returns_HistoryEvent_with_BaseEventType_field_list()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
// Events target the driver-root notifier (not a specific variable) which is the
|
||||
// conventional pattern for alarm-history browse.
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
||||
var nodeId = new NodeId("history-driver", nsIndex);
|
||||
|
||||
// EventFilter must carry at least one SelectClause or the stack rejects it as
|
||||
// BadEventFilterInvalid before our override runs — empty filters are spec-forbidden.
|
||||
// We populate the standard BaseEventType selectors any real client would send; my
|
||||
// override's BuildHistoryEvent ignores the specific clauses and emits the canonical
|
||||
// field list anyway (the richer "respect exact SelectClauses" behavior is on the PR 38
|
||||
// follow-up list).
|
||||
var filter = new EventFilter();
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Time);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.ReceiveTime);
|
||||
|
||||
var details = new ReadEventDetails
|
||||
{
|
||||
StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
EndTime = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc),
|
||||
NumValuesPerNode = 10,
|
||||
Filter = filter,
|
||||
};
|
||||
var extObj = new ExtensionObject(details);
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
||||
|
||||
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
||||
out var results, out _);
|
||||
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
var he = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||
he.Events.Count.ShouldBe(_driver.EventsReturned);
|
||||
he.Events[0].EventFields.Count.ShouldBe(6, "BaseEventType default field layout is 6 entries");
|
||||
}
|
||||
|
||||
private async Task<ISession> OpenSessionAsync()
|
||||
{
|
||||
var cfg = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUaHistoryTestClient",
|
||||
ApplicationUri = "urn:OtOpcUa:HistoryTestClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
||||
SubjectName = "CN=OtOpcUaHistoryTestClient",
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
AddAppCertToTrustedStore = true,
|
||||
},
|
||||
TransportConfigurations = new TransportConfigurationCollection(),
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||
};
|
||||
await cfg.Validate(ApplicationType.Client);
|
||||
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
||||
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
||||
|
||||
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
||||
var endpointConfig = EndpointConfiguration.Create(cfg);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
|
||||
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaHistoryTestClientSession", 60000,
|
||||
new UserIdentity(new AnonymousIdentityToken()), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub driver that implements <see cref="IHistoryProvider"/> so the service dispatch
|
||||
/// can be verified without bringing up a real Galaxy or Historian. Captures the last-
|
||||
/// seen arguments so tests can assert what the service handler forwarded.
|
||||
/// </summary>
|
||||
private sealed class HistoryDriver : IDriver, ITagDiscovery, IReadable, IHistoryProvider
|
||||
{
|
||||
public string DriverInstanceId => "history-driver";
|
||||
public string DriverType => "HistoryStub";
|
||||
|
||||
public int RawSamplesReturned => 3;
|
||||
public int FirstRawValue => 100;
|
||||
public int EventsReturned => 2;
|
||||
|
||||
public HistoryAggregateType? LastProcessedAggregate { get; private set; }
|
||||
public TimeSpan? LastProcessedInterval { get; private set; }
|
||||
public IReadOnlyList<DateTime>? LastAtTimeRequestedTimes { get; private set; }
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||
{
|
||||
// Every variable must be Historized for HistoryRead to route — the node-manager's
|
||||
// stack base class checks the bit before dispatching.
|
||||
builder.Variable("raw", "raw",
|
||||
new DriverAttributeInfo("raw.var", DriverDataType.Int32, false, null,
|
||||
SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false));
|
||||
builder.Variable("proc", "proc",
|
||||
new DriverAttributeInfo("proc.var", DriverDataType.Float64, false, null,
|
||||
SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false));
|
||||
builder.Variable("atTime", "atTime",
|
||||
new DriverAttributeInfo("atTime.var", DriverDataType.Int32, false, null,
|
||||
SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
IReadOnlyList<DataValueSnapshot> r =
|
||||
[.. fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now))];
|
||||
return Task.FromResult(r);
|
||||
}
|
||||
|
||||
public Task<DriverHistoryReadResult> ReadRawAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var samples = new List<DataValueSnapshot>();
|
||||
for (var i = 0; i < RawSamplesReturned; i++)
|
||||
{
|
||||
samples.Add(new DataValueSnapshot(
|
||||
Value: FirstRawValue + i,
|
||||
StatusCode: StatusCodes.Good,
|
||||
SourceTimestampUtc: startUtc.AddSeconds(i),
|
||||
ServerTimestampUtc: startUtc.AddSeconds(i)));
|
||||
}
|
||||
return Task.FromResult(new DriverHistoryReadResult(samples, null));
|
||||
}
|
||||
|
||||
public Task<DriverHistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
{
|
||||
LastProcessedAggregate = aggregate;
|
||||
LastProcessedInterval = interval;
|
||||
return Task.FromResult(new DriverHistoryReadResult(
|
||||
[new DataValueSnapshot(1.0, StatusCodes.Good, startUtc, startUtc)],
|
||||
null));
|
||||
}
|
||||
|
||||
public Task<DriverHistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LastAtTimeRequestedTimes = timestampsUtc;
|
||||
var samples = timestampsUtc
|
||||
.Select(t => new DataValueSnapshot(42, StatusCodes.Good, t, t))
|
||||
.ToArray();
|
||||
return Task.FromResult(new DriverHistoryReadResult(samples, null));
|
||||
}
|
||||
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var events = new List<HistoricalEvent>();
|
||||
for (var i = 0; i < EventsReturned; i++)
|
||||
{
|
||||
events.Add(new HistoricalEvent(
|
||||
EventId: $"e{i}",
|
||||
SourceName: sourceName,
|
||||
EventTimeUtc: startUtc.AddHours(i),
|
||||
ReceivedTimeUtc: startUtc.AddHours(i).AddSeconds(1),
|
||||
Message: $"Event {i}",
|
||||
Severity: (ushort)(500 + i)));
|
||||
}
|
||||
return Task.FromResult(new HistoricalEventsResult(events, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
197
tests/ZB.MOM.WW.OtOpcUa.Server.Tests/HostStatusPublisherTests.cs
Normal file
197
tests/ZB.MOM.WW.OtOpcUa.Server.Tests/HostStatusPublisherTests.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class HostStatusPublisherTests : IDisposable
|
||||
{
|
||||
private const string DefaultServer = "localhost,14330";
|
||||
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
|
||||
|
||||
private readonly string _databaseName = $"OtOpcUaPublisher_{Guid.NewGuid():N}";
|
||||
private readonly string _connectionString;
|
||||
private readonly ServiceProvider _sp;
|
||||
|
||||
public HostStatusPublisherTests()
|
||||
{
|
||||
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
|
||||
var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
|
||||
_connectionString =
|
||||
$"Server={server};Database={_databaseName};User Id=sa;Password={password};TrustServerCertificate=True;Encrypt=False;";
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDbContext<OtOpcUaConfigDbContext>(o => o.UseSqlServer(_connectionString));
|
||||
_sp = services.BuildServiceProvider();
|
||||
|
||||
using var scope = _sp.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>().Database.Migrate();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_sp.Dispose();
|
||||
using var conn = new Microsoft.Data.SqlClient.SqlConnection(
|
||||
new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(_connectionString) { InitialCatalog = "master" }.ConnectionString);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $@"
|
||||
IF DB_ID(N'{_databaseName}') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER DATABASE [{_databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||
DROP DATABASE [{_databaseName}];
|
||||
END";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publisher_upserts_one_row_per_host_reported_by_each_probe_driver()
|
||||
{
|
||||
var driverHost = new DriverHost();
|
||||
await driverHost.RegisterAsync(new ProbeStubDriver("driver-a",
|
||||
new HostConnectivityStatus("HostA1", HostState.Running, DateTime.UtcNow),
|
||||
new HostConnectivityStatus("HostA2", HostState.Stopped, DateTime.UtcNow)),
|
||||
"{}", CancellationToken.None);
|
||||
await driverHost.RegisterAsync(new NonProbeStubDriver("driver-no-probe"), "{}", CancellationToken.None);
|
||||
|
||||
var nodeOptions = NewNodeOptions("node-a");
|
||||
var publisher = new HostStatusPublisher(driverHost, nodeOptions, _sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<HostStatusPublisher>.Instance);
|
||||
|
||||
await publisher.PublishOnceAsync(CancellationToken.None);
|
||||
|
||||
using var scope = _sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
var rows = await db.DriverHostStatuses.AsNoTracking().ToListAsync();
|
||||
|
||||
rows.Count.ShouldBe(2, "driver-no-probe doesn't implement IHostConnectivityProbe — no rows for it");
|
||||
rows.ShouldContain(r => r.HostName == "HostA1" && r.State == DriverHostState.Running && r.DriverInstanceId == "driver-a");
|
||||
rows.ShouldContain(r => r.HostName == "HostA2" && r.State == DriverHostState.Stopped && r.DriverInstanceId == "driver-a");
|
||||
rows.ShouldAllBe(r => r.NodeId == "node-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_tick_updates_LastSeenUtc_without_creating_duplicate_rows()
|
||||
{
|
||||
var driver = new ProbeStubDriver("driver-x",
|
||||
new HostConnectivityStatus("HostX", HostState.Running, DateTime.UtcNow));
|
||||
var driverHost = new DriverHost();
|
||||
await driverHost.RegisterAsync(driver, "{}", CancellationToken.None);
|
||||
|
||||
var publisher = new HostStatusPublisher(driverHost, NewNodeOptions("node-x"),
|
||||
_sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<HostStatusPublisher>.Instance);
|
||||
|
||||
await publisher.PublishOnceAsync(CancellationToken.None);
|
||||
var firstSeen = await SingleRowAsync("node-x", "driver-x", "HostX");
|
||||
await Task.Delay(50); // guarantee a later wall-clock value so LastSeenUtc advances
|
||||
await publisher.PublishOnceAsync(CancellationToken.None);
|
||||
var secondSeen = await SingleRowAsync("node-x", "driver-x", "HostX");
|
||||
|
||||
secondSeen.LastSeenUtc.ShouldBeGreaterThan(firstSeen.LastSeenUtc,
|
||||
"heartbeat advances LastSeenUtc so Admin can stale-flag rows from crashed Servers");
|
||||
|
||||
// Still exactly one row — a naive Add-every-tick would have thrown or duplicated.
|
||||
using var scope = _sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
(await db.DriverHostStatuses.CountAsync(r => r.NodeId == "node-x")).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task State_change_between_ticks_updates_State_and_StateChangedUtc()
|
||||
{
|
||||
var driver = new ProbeStubDriver("driver-y",
|
||||
new HostConnectivityStatus("HostY", HostState.Running, DateTime.UtcNow.AddSeconds(-10)));
|
||||
var driverHost = new DriverHost();
|
||||
await driverHost.RegisterAsync(driver, "{}", CancellationToken.None);
|
||||
|
||||
var publisher = new HostStatusPublisher(driverHost, NewNodeOptions("node-y"),
|
||||
_sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<HostStatusPublisher>.Instance);
|
||||
|
||||
await publisher.PublishOnceAsync(CancellationToken.None);
|
||||
var before = await SingleRowAsync("node-y", "driver-y", "HostY");
|
||||
|
||||
// Swap the driver's reported state to Faulted with a newer transition timestamp.
|
||||
var newChange = DateTime.UtcNow;
|
||||
driver.Statuses = [new HostConnectivityStatus("HostY", HostState.Faulted, newChange)];
|
||||
await publisher.PublishOnceAsync(CancellationToken.None);
|
||||
|
||||
var after = await SingleRowAsync("node-y", "driver-y", "HostY");
|
||||
after.State.ShouldBe(DriverHostState.Faulted);
|
||||
// datetime2(3) has millisecond precision — DateTime.UtcNow carries up to 100ns ticks,
|
||||
// so the stored value rounds down. Compare at millisecond granularity to stay clean.
|
||||
after.StateChangedUtc.ShouldBe(newChange, tolerance: TimeSpan.FromMilliseconds(1));
|
||||
after.StateChangedUtc.ShouldBeGreaterThan(before.StateChangedUtc,
|
||||
"StateChangedUtc must advance when the state actually changed");
|
||||
before.State.ShouldBe(DriverHostState.Running);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapState_translates_every_HostState_member()
|
||||
{
|
||||
HostStatusPublisher.MapState(HostState.Running).ShouldBe(DriverHostState.Running);
|
||||
HostStatusPublisher.MapState(HostState.Stopped).ShouldBe(DriverHostState.Stopped);
|
||||
HostStatusPublisher.MapState(HostState.Faulted).ShouldBe(DriverHostState.Faulted);
|
||||
HostStatusPublisher.MapState(HostState.Unknown).ShouldBe(DriverHostState.Unknown);
|
||||
}
|
||||
|
||||
private async Task<Configuration.Entities.DriverHostStatus> SingleRowAsync(string node, string driver, string host)
|
||||
{
|
||||
using var scope = _sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
return await db.DriverHostStatuses.AsNoTracking()
|
||||
.SingleAsync(r => r.NodeId == node && r.DriverInstanceId == driver && r.HostName == host);
|
||||
}
|
||||
|
||||
private static NodeOptions NewNodeOptions(string nodeId) => new()
|
||||
{
|
||||
NodeId = nodeId,
|
||||
ClusterId = "cluster-t",
|
||||
ConfigDbConnectionString = "unused-publisher-gets-db-from-scope",
|
||||
};
|
||||
|
||||
private sealed class ProbeStubDriver(string id, params HostConnectivityStatus[] initial)
|
||||
: IDriver, IHostConnectivityProbe
|
||||
{
|
||||
public HostConnectivityStatus[] Statuses { get; set; } = initial;
|
||||
public string DriverInstanceId => id;
|
||||
public string DriverType => "ProbeStub";
|
||||
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() => Statuses;
|
||||
|
||||
// Keeps the compiler happy — event is part of the interface contract even if unused here.
|
||||
internal void Raise(HostStatusChangedEventArgs e) => OnHostStatusChanged?.Invoke(this, e);
|
||||
}
|
||||
|
||||
private sealed class NonProbeStubDriver(string id) : IDriver
|
||||
{
|
||||
public string DriverInstanceId => id;
|
||||
public string DriverType => "NonProbeStub";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic guards for Active Directory compatibility of the internal helpers
|
||||
/// <see cref="LdapUserAuthenticator"/> relies on. We can't live-bind against AD in unit
|
||||
/// tests — instead, we pin the behaviors AD depends on (DN-parsing of AD-style
|
||||
/// <c>memberOf</c> values, filter escaping with case-preserving RDN extraction) so a
|
||||
/// future refactor can't silently break the AD path while the GLAuth live-smoke stays
|
||||
/// green.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class LdapUserAuthenticatorAdCompatTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExtractFirstRdnValue_parses_AD_memberOf_group_name_from_CN_dn()
|
||||
{
|
||||
// AD's memberOf values use uppercase CN=… and full domain paths. The extractor
|
||||
// returns the first RDN's value regardless of attribute-type case, so operators'
|
||||
// GroupToRole keys stay readable ("OPCUA-Operators" not "CN=OPCUA-Operators,...").
|
||||
var dn = "CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com";
|
||||
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("OPCUA-Operators");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractFirstRdnValue_handles_mixed_case_and_spaces_in_group_name()
|
||||
{
|
||||
var dn = "CN=Domain Users,CN=Users,DC=corp,DC=example,DC=com";
|
||||
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("Domain Users");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractFirstRdnValue_also_works_for_OpenLDAP_ou_style_memberOf()
|
||||
{
|
||||
// GLAuth + some OpenLDAP deployments expose memberOf as ou=<group>,ou=groups,...
|
||||
// The authenticator needs one extractor that tolerates both shapes since directories
|
||||
// in the field mix them depending on schema.
|
||||
var dn = "ou=WriteOperate,ou=groups,dc=lmxopcua,dc=local";
|
||||
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("WriteOperate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EscapeLdapFilter_prevents_injection_via_samaccountname_lookup()
|
||||
{
|
||||
// AD login names can contain characters that are meaningful to LDAP filter syntax
|
||||
// (parens, backslashes). The authenticator builds filters as
|
||||
// ($"({UserNameAttribute}={EscapeLdapFilter(username)})") so injection attempts must
|
||||
// not break out of the filter. The RFC 4515 escape set is: \ → \5c, * → \2a, ( → \28,
|
||||
// ) → \29, \0 → \00.
|
||||
LdapUserAuthenticator.EscapeLdapFilter("admin)(cn=*")
|
||||
.ShouldBe("admin\\29\\28cn=\\2a");
|
||||
LdapUserAuthenticator.EscapeLdapFilter("domain\\user")
|
||||
.ShouldBe("domain\\5cuser");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapOptions_default_UserNameAttribute_is_uid_for_rfc2307_compat()
|
||||
{
|
||||
// Regression guard: PR 31 introduced UserNameAttribute with a default of "uid" so
|
||||
// existing deployments (pre-AD config) keep working. Changing the default breaks
|
||||
// everyone's config silently; require an explicit review.
|
||||
new LdapOptions().UserNameAttribute.ShouldBe("uid");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Live-service tests against the dev GLAuth instance at <c>localhost:3893</c>. Skipped
|
||||
/// when the port is unreachable so the test suite stays portable on boxes without a
|
||||
/// running directory. Closes LMX follow-up #4 — the server-side <see cref="LdapUserAuthenticator"/>
|
||||
/// is exercised end-to-end against a real LDAP server (same one the Admin process uses),
|
||||
/// not just the flow-shape unit tests from PR 19.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <c>Admin.Tests</c> project already has a live-bind test for its own
|
||||
/// <c>LdapAuthService</c>; this pair catches divergence between the two bind paths — the
|
||||
/// Server authenticator has to work even when the Server process is on a machine that
|
||||
/// doesn't have the Admin assemblies loaded, and the two share no code by design
|
||||
/// (cross-app dependency avoidance). If one side drifts past the other on LDAP filter
|
||||
/// construction, DN resolution, or memberOf parsing, these tests surface it.
|
||||
/// </remarks>
|
||||
[Trait("Category", "LiveLdap")]
|
||||
public sealed class LdapUserAuthenticatorLiveTests
|
||||
{
|
||||
private const string GlauthHost = "localhost";
|
||||
private const int GlauthPort = 3893;
|
||||
|
||||
private static bool GlauthReachable()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync(GlauthHost, GlauthPort);
|
||||
return task.Wait(TimeSpan.FromSeconds(1)) && client.Connected;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
// GLAuth dev directory groups are named identically to the OPC UA roles
|
||||
// (ReadOnly / WriteOperate / WriteTune / WriteConfigure / AlarmAck), so the map is an
|
||||
// identity translation. The authenticator still exercises every step of the pipeline —
|
||||
// bind, memberOf lookup, group-name extraction, GroupToRole lookup — against real LDAP
|
||||
// data; the identity map just means the assertion is phrased with no surprise rename
|
||||
// in the middle.
|
||||
private static LdapOptions GlauthOptions() => new()
|
||||
{
|
||||
Enabled = true,
|
||||
Server = GlauthHost,
|
||||
Port = GlauthPort,
|
||||
UseTls = false,
|
||||
AllowInsecureLdap = true,
|
||||
SearchBase = "dc=lmxopcua,dc=local",
|
||||
// Search-then-bind: service account resolves the user's full DN (cn=<user> lives
|
||||
// under ou=<primary-group>,ou=users), the authenticator binds that DN with the
|
||||
// user's password, then stays on the service-account session for memberOf lookup.
|
||||
// Without this path, GLAuth ACLs block the authenticated user from reading their
|
||||
// own entry in full — a plain self-search returns zero results and the role list
|
||||
// ends up empty.
|
||||
ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
ServiceAccountPassword = "serviceaccount123",
|
||||
DisplayNameAttribute = "cn",
|
||||
GroupAttribute = "memberOf",
|
||||
UserNameAttribute = "cn", // GLAuth keys users by cn — see LdapOptions xml-doc.
|
||||
GroupToRole = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ReadOnly"] = "ReadOnly",
|
||||
["WriteOperate"] = WriteAuthzPolicy.RoleWriteOperate,
|
||||
["WriteTune"] = WriteAuthzPolicy.RoleWriteTune,
|
||||
["WriteConfigure"] = WriteAuthzPolicy.RoleWriteConfigure,
|
||||
["AlarmAck"] = "AlarmAck",
|
||||
},
|
||||
};
|
||||
|
||||
private static LdapUserAuthenticator NewAuthenticator() =>
|
||||
new(GlauthOptions(), NullLogger<LdapUserAuthenticator>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_credentials_bind_and_return_success()
|
||||
{
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||
|
||||
var result = await NewAuthenticator().AuthenticateAsync("readonly", "readonly123", TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.ShouldBeTrue(result.Error);
|
||||
result.DisplayName.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writeop_user_gets_WriteOperate_role_from_group_mapping()
|
||||
{
|
||||
// Drives end-to-end: bind as writeop, memberOf lists the WriteOperate group, the
|
||||
// authenticator surfaces WriteOperate via GroupToRole. If this test fails,
|
||||
// WriteAuthzPolicy.IsAllowed for an Operate-tier write would also fail
|
||||
// (WriteOperate is the exact string the policy checks for), so the failure mode is
|
||||
// concrete, not abstract.
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||
|
||||
var result = await NewAuthenticator().AuthenticateAsync("writeop", "writeop123", TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.ShouldBeTrue(result.Error);
|
||||
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_user_gets_multiple_roles_from_multiple_groups()
|
||||
{
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||
|
||||
// 'admin' has primarygroup=ReadOnly and othergroups=[WriteOperate, AlarmAck,
|
||||
// WriteTune, WriteConfigure] per the GLAuth dev config — the authenticator must
|
||||
// surface every mapped role, not just the primary group. Guards against a regression
|
||||
// where the memberOf parsing stops after the first match or misses the primary-group
|
||||
// fallback.
|
||||
var result = await NewAuthenticator().AuthenticateAsync("admin", "admin123", TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.ShouldBeTrue(result.Error);
|
||||
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
|
||||
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteTune);
|
||||
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteConfigure);
|
||||
result.Roles.ShouldContain("AlarmAck");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_password_returns_failure()
|
||||
{
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||
|
||||
var result = await NewAuthenticator().AuthenticateAsync("readonly", "wrong-pw", TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_user_returns_failure()
|
||||
{
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||
|
||||
var result = await NewAuthenticator().AuthenticateAsync("no-such-user-42", "whatever", TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_credentials_fail_without_touching_the_directory()
|
||||
{
|
||||
// Pre-flight guard — doesn't require GLAuth.
|
||||
var result = await NewAuthenticator().AuthenticateAsync("", "", TestContext.Current.CancellationToken);
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldContain("Credentials", Case.Insensitive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Closes LMX follow-up #6 — proves that two <see cref="IDriver"/> instances registered
|
||||
/// on the same <see cref="DriverHost"/> land in isolated namespaces and their reads
|
||||
/// route to the correct driver. The existing <see cref="OpcUaServerIntegrationTests"/>
|
||||
/// only exercises a single-driver topology; this sibling fixture registers two.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each driver gets its own namespace URI of the form <c>urn:OtOpcUa:{DriverInstanceId}</c>
|
||||
/// (per <c>DriverNodeManager</c>'s base-class <c>namespaceUris</c> argument). A client
|
||||
/// that browses one namespace must see only that driver's subtree, and a read against a
|
||||
/// variable in one namespace must return that driver's value, not the other's — this is
|
||||
/// what stops a cross-driver routing regression from going unnoticed when the v1
|
||||
/// single-driver code path gets new knobs.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class MultipleDriverInstancesIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly int Port = 48500 + Random.Shared.Next(0, 99);
|
||||
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaMultiDriverTest";
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multi-{Guid.NewGuid():N}");
|
||||
|
||||
private DriverHost _driverHost = null!;
|
||||
private OpcUaApplicationHost _server = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_driverHost = new DriverHost();
|
||||
await _driverHost.RegisterAsync(new StubDriver("alpha", folderName: "AlphaFolder", readValue: 42),
|
||||
"{}", CancellationToken.None);
|
||||
await _driverHost.RegisterAsync(new StubDriver("beta", folderName: "BetaFolder", readValue: 99),
|
||||
"{}", CancellationToken.None);
|
||||
|
||||
var options = new OpcUaServerOptions
|
||||
{
|
||||
EndpointUrl = _endpoint,
|
||||
ApplicationName = "OtOpcUaMultiDriverTest",
|
||||
ApplicationUri = "urn:OtOpcUa:Server:MultiDriverTest",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
};
|
||||
|
||||
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
|
||||
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
|
||||
await _server.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
await _driverHost.DisposeAsync();
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Both_drivers_register_under_their_own_urn_namespace()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
|
||||
var alphaNs = session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
|
||||
var betaNs = session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
|
||||
|
||||
alphaNs.ShouldBeGreaterThanOrEqualTo(0, "DriverNodeManager for 'alpha' must register its namespace URI");
|
||||
betaNs.ShouldBeGreaterThanOrEqualTo(0, "DriverNodeManager for 'beta' must register its namespace URI");
|
||||
alphaNs.ShouldNotBe(betaNs, "each driver owns its own namespace");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_driver_subtree_exposes_only_its_own_folder()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
|
||||
var alphaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
|
||||
var betaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
|
||||
|
||||
var alphaRoot = new NodeId("alpha", alphaNs);
|
||||
session.Browse(null, null, alphaRoot, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences,
|
||||
true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var alphaRefs);
|
||||
alphaRefs.ShouldContain(r => r.BrowseName.Name == "AlphaFolder",
|
||||
"alpha's subtree must contain alpha's folder");
|
||||
alphaRefs.ShouldNotContain(r => r.BrowseName.Name == "BetaFolder",
|
||||
"alpha's subtree must NOT see beta's folder — cross-driver leak would hide subscription-routing bugs");
|
||||
|
||||
var betaRoot = new NodeId("beta", betaNs);
|
||||
session.Browse(null, null, betaRoot, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences,
|
||||
true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var betaRefs);
|
||||
betaRefs.ShouldContain(r => r.BrowseName.Name == "BetaFolder");
|
||||
betaRefs.ShouldNotContain(r => r.BrowseName.Name == "AlphaFolder");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_route_to_the_correct_driver_by_namespace()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
|
||||
var alphaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
|
||||
var betaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
|
||||
|
||||
var alphaValue = session.ReadValue(new NodeId("AlphaFolder.Var1", alphaNs));
|
||||
var betaValue = session.ReadValue(new NodeId("BetaFolder.Var1", betaNs));
|
||||
|
||||
alphaValue.Value.ShouldBe(42, "alpha driver's ReadAsync returns 42 — a misroute would surface as 99");
|
||||
betaValue.Value.ShouldBe(99, "beta driver's ReadAsync returns 99 — a misroute would surface as 42");
|
||||
}
|
||||
|
||||
private async Task<ISession> OpenSessionAsync()
|
||||
{
|
||||
var cfg = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUaMultiDriverTestClient",
|
||||
ApplicationUri = "urn:OtOpcUa:MultiDriverTestClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
||||
SubjectName = "CN=OtOpcUaMultiDriverTestClient",
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
AddAppCertToTrustedStore = true,
|
||||
},
|
||||
TransportConfigurations = new TransportConfigurationCollection(),
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||
};
|
||||
await cfg.Validate(ApplicationType.Client);
|
||||
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
||||
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
||||
|
||||
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
||||
var endpointConfig = EndpointConfiguration.Create(cfg);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
|
||||
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaMultiDriverTestClientSession", 60000,
|
||||
new UserIdentity(new AnonymousIdentityToken()), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver stub that returns a caller-specified folder + variable + read value so two
|
||||
/// instances in the same server can be told apart at the assertion layer.
|
||||
/// </summary>
|
||||
private sealed class StubDriver(string driverInstanceId, string folderName, int readValue)
|
||||
: IDriver, ITagDiscovery, IReadable
|
||||
{
|
||||
public string DriverInstanceId => driverInstanceId;
|
||||
public string DriverType => "Stub";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||
{
|
||||
var folder = builder.Folder(folderName, folderName);
|
||||
folder.Variable("Var1", "Var1", new DriverAttributeInfo(
|
||||
$"{folderName}.Var1", DriverDataType.Int32, false, null, SecurityClassification.FreeAccess, false, IsAlarm: false));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
IReadOnlyList<DataValueSnapshot> result =
|
||||
fullReferences.Select(_ => new DataValueSnapshot(readValue, 0u, now, now)).ToArray();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user