Compare commits

..

9 Commits

Author SHA1 Message Date
Joseph Doherty
9de96554dc Phase 3 PR 41 — Document AutomationDirect DL205 / DL260 Modbus quirks. Adds docs/v2/dl205.md (~300 lines, 8 H2 sections, primary-source citations) covering every place the DL205/DL260 family diverges from textbook Modbus or has non-obvious behavior a generic client gets wrong. Replaces the placeholder _pending_ list in modbus-test-plan.md with a confirmed-behaviors table that doubles as the integration-test roadmap.
The user explicitly flagged that DL205/DL260 strings don't follow Modbus convention; research turned up that and a lot more. Headline findings:
String packing — TWO chars per V-memory register but the FIRST char is in the LOW byte (opposite of the big-endian Modbus convention generic drivers default to). 'Hello' in V2000 reads back as 'eHll o\0' on a textbook decoder. Kepware's DirectLogic driver exposes a per-tag 'String Byte Order = Low/High' toggle specifically for this; we'll need the same. Null-terminated, no length prefix, no dedicated KSTR address space — strings live wherever ladder allocates them in V-memory.
V-memory addressing — DirectLOGIC's native V-memory is OCTAL (V2000, V40400) but Modbus is decimal. The CPU translates: V2000 octal = decimal 1024 = Modbus PDU 0x0400. The widespread 'V40400 = register 0' shorthand is wrong on modern firmware (that was DL05/DL06 relative mode); on H2-ECOM100 absolute mode (factory default) V40400 = PDU 0x2100. We'd surface this with an address-format helper in the device profile so operators write V2000 instead of computing 1024 by hand.
Word order CDAB for all 32-bit values — DL205 and DL260 agree, ECOM modules don't re-swap. Already supported via ModbusByteOrder.WordSwap; just needs to be the default in the DL205 profile.
BCD-as-default numeric storage — bit one I didn't expect. DirectLOGIC stores 'V2000 = 1234' as 0x1234 on the wire (BCD nibbles), not as 0x04D2 (decimal 1234). IEEE 754 Float32 only works when ladder used the explicit R type (LDR/OUTR instructions). We need a new decoder mode for BCD-encoded registers — current code assumes binary integers.
FC quantity caps — FC03/04 cap at 128 (above spec's 125 — Bonus territory, current code already respects 125), FC16 caps at 100 (BELOW spec's 123 — important bulk-write batching gotcha). Quantity overrun returns exception 03 IllegalDataValue.
Coil/discrete mappings — DL260: X0->discrete input 0, Y0->coil 2048, C0->coil 3072. SP specials at discrete input 1024-1535 RO. These are CPU-wired constants and cannot be remapped; need to be hardcoded in the DL205/DL260 device profile.
Register 0 — accepted on DL205/DL260 with ECOM in absolute mode, contrary to the widespread internet claim that 'DirectLOGIC rejects register 0'. That rumour was an older DL05/DL06 relative-mode artefact. Our ModbusProbeOptions.ProbeAddress default of 0 is therefore safe for DL205/DL260.
Exception codes — only the standard 01-04. Write-to-protected-bit returns 02 on newer firmware, 04 on older (firmware-transition revision unconfirmed); driver should map both to BadNotWritable. No proprietary exception codes.
Behavioral oddities — H2-ECOM100 accepts MAX 4 simultaneous TCP connections (5th refused at TCP accept). No TCP keepalive (intermediate NAT/firewall drops idle sockets after 2-5 min — periodic probe required). No mid-stream resync on malformed MBAP — driver must reconnect + replay. TxId-drop-under-load forum rumour is unconfirmed; our single-flight + TxId-match guard handles it either way.
Each H2 section ends with the integration-test names we'd ship per the modbus-test-plan.md DL205_<behavior> convention — twelve named test slots ready for PR 42+ to fill in one at a time. References (8) cited inline, primarily D2-USER-M, HA-ECOM-M, and the Kepware DirectLogic Ethernet driver manual which documents these vendor quirks explicitly because they have to cope with them.
modbus-test-plan.md DL205 section rewritten as a priority-ordered table with three columns (quirk / driver impact / test name), pointing the reader at dl205.md for the full reference. Operator-reported items separated into a tail subsection so future-me knows which behaviors are documented vs reproduced-on-hardware.
Pure documentation PR — no code changes. The actual driver work (string-byte-order option, BCD decoder mode, V-memory address helper, FC16 cap-per-device-family, multi-client TCP handling) lands one PR per quirk in PR 42+ as ModbusPal validation completes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:49:35 -04:00
af35fac0ef Merge pull request 'Phase 3 PR 40 — LiveStack write + subscribe tests against TestMachine_001' (#39) from phase-3-pr40-livestack-write-subscribe into v2 2026-04-18 19:41:55 -04:00
Joseph Doherty
aa8834a231 Phase 3 PR 40 — LiveStackSmokeTests: write-roundtrip + subscribe-receives-OnDataChange against the live Galaxy. Finishes LMX #5 by exercising the IWritable + ISubscribable capability paths end-to-end through the Proxy → OtOpcUaGalaxyHost service → MXAccess → real Galaxy.
Two new facts target DelmiaReceiver_001.TestAttribute — the writable Boolean UDA on the TestMachine_001 hierarchy in this dev Galaxy. The user nominated TestMachine_001 (the deployed test-target object) as a scratch surface for live testing; ZB query showed DelmiaReceiver_001 carries one dynamic_attribute named TestAttribute (mx_data_type=1=Boolean, lock_type=0=writable, security_classification=1=Operate). Naming makes the intent obvious — the attribute exists for exactly this kind of integration testing — and Boolean keeps the assertions simple (invert, write, read back).
Write_then_read_roundtrips_a_writable_Boolean_attribute_on_TestMachine_001: reads the current value as the baseline (Galaxy may return Uncertain quality until the Engine has scanned the attribute at least once — we don't read into a typed bool until Status is Good), inverts it, writes via IWritable, then polls reads in a 5s loop until either the new value comes back or the budget expires. The scan-window poll (rather than a single read after a fixed delay) accommodates Galaxy's variable scan latency on a fresh service start. Restore-on-finally writes the original value back so re-running the test doesn't accumulate a flipped TestAttribute on the dev box (Galaxy holds UDA values across runs since they're deployed). Best-effort restore — swallows exceptions so a failure in restore doesn't mask the primary assertion.
Subscribe_fires_OnDataChange_with_initial_value_then_again_after_a_write: subscribes to the same attribute with a 250ms publishing interval, captures every OnDataChange notification onto a thread-safe ConcurrentQueue (MXAccess advisory fires on its own thread per Galaxy's COM apartment model — must not block it), waits up to 5s for the initial-value callback (per ISubscribable's contract: 'driver MAY fire OnDataChange immediately with the current value'), records the queue depth as a baseline, writes the toggled value, waits up to 8s for at least one MORE notification, then searches the queue tail for the notification carrying the toggled value (initial value may appear multiple times before the write commits — looking at the tail finds the post-write delta even if the queue grew during the wait window). Unsubscribes on finally + restores baseline.
Both tests use Convert.ToBoolean(value ?? false) to defensively handle the Boxed-vs-typed quirk in MessagePack-deserialized Galaxy values — depending on the wire encoding the Boolean might come back as System.Boolean or System.Object boxing one. Convert.ToBoolean handles both. Same pattern in OnReadValue's existing usage.
WaitForAsync helper does the loop+budget pattern shared by both tests.
PR 40 is the code side of LMX #5's final two deferred facts. To actually run them green requires re-executing from a normal (non-admin) PowerShell — the elevated-shell skip from PR 39 fires correctly under bash + sc.exe-context (verified). lmx-followups.md #5 updated to note the new facts + the run command + the one remaining genuine follow-up (alarm-condition fact when an alarm-flagged attribute is deployed on TestMachine_001).
Test posture from elevated bash: 7 LiveStackSmokeTests facts discovered (was 5; +2 new), all skip cleanly with the elevation message. Build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:38:34 -04:00
976e73e051 Merge pull request 'Phase 3 PR 39 — LiveStackFixture skip-with-reason for elevated shells' (#38) from phase-3-pr39-elevated-shell-skip into v2 2026-04-18 19:31:30 -04:00
Joseph Doherty
8fb3dbe53b Phase 3 PR 39 — LiveStackFixture pre-flight detect for elevated shell. The OtOpcUaGalaxyHost named-pipe ACL allows the configured SID but explicitly DENIES Administrators per decision #76 / PipeAcl.cs (production-hardening — keeps an admin shell on a deployed box from connecting to the IPC channel without going through the configured service principal). A test process running with a high-integrity elevated token carries the Administrators group in its security context regardless of whose user it 'is', so the deny rule trumps the user's allow and the pipe connect returns UnauthorizedAccessException at the prerequisite-probe stage. Functionally correct but operationally confusing — when this hit during the PR 38 install workflow it took five steps to diagnose ('the user IS in the allow list, why is the pipe denying access?'). The pre-existing ParityFixture (PR 18) already documents this with an explicit early-skip; LiveStackFixture (PR 37) didn't.
PR 39 closes the gap. New IsElevatedAdministratorOnWindows static helper (Windows-only via RuntimeInformation.IsOSPlatform; non-Windows hosts return false and let the prerequisite probe own the skip-with-reason path) checks WindowsPrincipal.IsInRole(WindowsBuiltInRole.Administrator) on the current process token. When true, InitializeAsync short-circuits to a SkipReason that names the cause directly: 'elevated token's Admins group membership trumps the allow rule — re-run from a NORMAL (non-admin) PowerShell window'. Catches and swallows any probe-side exception so a Win32 oddity can't crash the test fixture; failed probe falls through to the regular prerequisite path.
The check fires BEFORE AvevaPrerequisites.CheckAllAsync runs because the prereq probe's own pipe connect hits the same admin-deny and surfaces UnauthorizedAccessException with no context. Short-circuiting earlier saves the 10-second probe + produces a single actionable line.
Tests — verified manually from an elevated bash session against the just-installed OtOpcUaGalaxyHost service: skip message reads '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.' Proxy.Tests Unit: 17 pass / 0 fail (unchanged — fixture change is non-breaking; existing tests don't run as admin in normal CI flow). Build clean.
Bonus: gitignored .local/ directory (a previous direct commit on local v2 that I'm now landing here) so per-install secrets like the Galaxy.Host shared-secret file don't leak into the repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:17:43 -04:00
Joseph Doherty
a61e637411 Gitignore .local/ directory for dev-only secrets like the Galaxy.Host shared secret. Created during the PR 38 / install-services workflow to keep per-install secrets out of the repo. 2026-04-18 19:15:13 -04:00
e4885aadd0 Merge pull request 'Phase 3 PR 38 — DriverNodeManager HistoryRead override (LMX #1 finish)' (#37) from phase-3-pr38-historyread-servicehandler into v2 2026-04-18 17:53:24 -04:00
Joseph Doherty
52a29100b1 Phase 3 PR 38 — DriverNodeManager HistoryRead override (LMX #1 finish). Wires the OPC UA HistoryRead service through CustomNodeManager2's four protected per-kind hooks — HistoryReadRawModified / HistoryReadProcessed / HistoryReadAtTime / HistoryReadEvents — each dispatching to the driver's IHistoryProvider capability (PR 35 for ReadAtTime + ReadEvents on top of PR 19-era ReadRaw + ReadProcessed). Was the last missing piece of the end-to-end HistoryRead path: PR 10 + PR 11 shipped the Galaxy.Host IPC contracts, PR 35 surfaced them on IHistoryProvider + GalaxyProxyDriver, but no server-side handler bridged OPC UA HistoryRead service requests onto the capability interface. Now it does.
Per-kind override shape: each hook receives the pre-filtered nodesToProcess list (NodeHandles for nodes this manager claimed), iterates them, resolves handle.NodeId.Identifier to the driver-side full reference string, and dispatches to the right IHistoryProvider method. Write back into the outer results + errors slots at handle.Index (not the local loop counter — nodesToProcess is a filtered subset of nodesToRead, so indexing by the loop counter lands in the wrong slot for mixed-manager batches). WriteResult helper sets both results[i] AND errors[i]; this matters because MasterNodeManager merges them and leaving errors[i] at its default (BadHistoryOperationUnsupported) overrides a Good result with Unsupported on the wire — this was the subtle failure mode that masked a correctly-constructed HistoryData response during debugging. Failure-isolation per node: NotSupportedException from a driver that doesn't implement a particular HistoryProvider method translates to BadHistoryOperationUnsupported in that slot; generic exceptions log and surface BadInternalError; unresolvable NodeIds get BadNodeIdUnknown. The batch continues unconditionally.
Aggregate mapping: MapAggregate translates ObjectIds.AggregateFunction_Average / Minimum / Maximum / Total / Count to the driver's HistoryAggregateType enum. Null for anything else (e.g. TimeAverage, Interpolative) so the handler surfaces BadAggregateNotSupported at the batch level — per Part 13, one unsupported aggregate means the whole request fails since ReadProcessedDetails carries one aggregate list for all nodes. BuildHistoryData wraps driver DataValueSnapshots as Opc.Ua.HistoryData in an ExtensionObject; BuildHistoryEvent wraps HistoricalEvents as Opc.Ua.HistoryEvent with the canonical BaseEventType field list (EventId, SourceName, Message, Severity, Time, ReceiveTime — the order OPC UA clients that didn't customize the SelectClause expect). ToDataValue preserves null SourceTimestamp (Galaxy historian rows often carry only ServerTimestamp) — synthesizing a SourceTimestamp would lie about actual sample time.
Two address-space changes were required to make the stack dispatch reach the per-kind hooks at all: (1) historized variables get AccessLevels.HistoryRead added to their AccessLevel byte — the base's early-gate check on (variable.AccessLevel & HistoryRead != 0) was rejecting requests before our override ever ran; (2) the driver-root folder gets EventNotifiers.HistoryRead | SubscribeToEvents so HistoryReadEvents can target it (the conventional pattern for alarm-history browse against a driver-owned object). Document the 'set both bits' requirement inline since it's not obvious from the surface API.
OpcHistoryReadResult alias: Opc.Ua.HistoryReadResult (service-layer per-node result) collides with Core.Abstractions.HistoryReadResult (driver-side samples + continuation point) by type name; the alias 'using OpcHistoryReadResult = Opc.Ua.HistoryReadResult' keeps the override signatures unambiguous and the test project applies the mirror pattern for its stub driver impl.
Tests — DriverNodeManagerHistoryMappingTests (12 new Category=Unit cases): MapAggregate translates each supported aggregate NodeId via reflection-backed theory (guards against the stack renaming AggregateFunction_* constants); returns null for unsupported NodeIds (TimeAverage) and null input; BuildHistoryData wraps samples with correct DataValues + SourceTimestamp preservation; BuildHistoryEvent emits the 6-element BaseEventType field list in canonical order (regression guard for a future 'respect the client's SelectClauses' change); null SourceName / Message translate to empty-string Variants (nullable-Variant refactor trap); ToDataValue preserves StatusCode + both timestamps; ToDataValue leaves SourceTimestamp at default when the snapshot omits it. HistoryReadIntegrationTests (5 new Category=Integration): drives a real OPC UA client Session.HistoryRead against a fake HistoryDriver through the running server. Covers raw round-trip (verifies per-node DataValue ordering + values); processed with Average aggregate (captures the driver's received aggregate + interval, asserting MapAggregate routed correctly); unsupported aggregate (TimeAverage → BadAggregateNotSupported); at-time (forwards the per-timestamp list); events (BaseEventType field list shape, SelectClauses populated to satisfy the stack's filter validator). Server.Tests Unit: 55 pass / 0 fail (43 prior + 12 new mapping). Server.Tests Integration: 14 pass / 0 fail (9 prior + 5 new history). Full solution build clean, 0 errors.
lmx-followups.md #1 updated to 'DONE (PRs 35 + 38)' with two explicit deferred items: continuation-point plumbing (driver returns null today so pass-through is fine) and per-SelectClause evaluation in HistoryReadEvents (clients with custom field selections get the canonical BaseEventType layout today).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:50:23 -04:00
19bcf20fbe Merge pull request 'Phase 3 PR 37 — End-to-end live-stack Galaxy smoke test' (#36) from phase-3-pr37-live-stack-smoke into v2 2026-04-18 16:56:50 -04:00
9 changed files with 1475 additions and 52 deletions

1
.gitignore vendored
View File

@@ -29,3 +29,4 @@ packages/
# Claude Code (per-developer settings, runtime lock files, agent transcripts)
.claude/
.local/

295
docs/v2/dl205.md Normal file
View 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.

View File

@@ -7,24 +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`
**Status**: Capability surface complete (PR 35). OPC UA HistoryRead service-handler
wiring in `DriverNodeManager` remains as the next step; integration-test still
pending.
## 1. Proxy-side `IHistoryProvider` for `ReadAtTime` / `ReadEvents` — **DONE (PRs 35 + 38)**
PR 35 extended `IHistoryProvider` with `ReadAtTimeAsync` + `ReadEventsAsync`
(default throwing implementations so existing impls keep compiling), added the
`HistoricalEvent` + `HistoricalEventsResult` records to
`Core.Abstractions`, and implemented both methods in `GalaxyProxyDriver` on top
of the PR 10 / PR 11 IPC messages. Wire-to-domain mapping (`ToHistoricalEvent`)
is unit-tested for field fidelity, null-preservation, and `DateTimeKind.Utc`.
`HistoricalEvent` + `HistoricalEventsResult` records to `Core.Abstractions`,
and implemented both methods in `GalaxyProxyDriver` on top of the PR 10 / PR 11
IPC messages.
**Remaining**:
- `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.
PR 38 wired the OPC UA HistoryRead service-handler through
`DriverNodeManager` by overriding `CustomNodeManager2`'s four per-kind hooks —
`HistoryReadRawModified` / `HistoryReadProcessed` / `HistoryReadAtTime` /
`HistoryReadEvents`. Each walks `nodesToProcess`, resolves the driver-side
full reference from `NodeId.Identifier`, dispatches to the right
`IHistoryProvider` method, and populates the paired results + errors lists
(both must be set — the MasterNodeManager merges them and a Good result with
an unset error slot serializes as `BadHistoryOperationUnsupported` on the
wire). Historized variables gain `AccessLevels.HistoryRead` so the stack
dispatches; the driver root folder gains `EventNotifiers.HistoryRead` so
`HistoryReadEvents` can target it.
Aggregate translation uses a small `MapAggregate` helper that handles
`Average` / `Minimum` / `Maximum` / `Total` / `Count` (the enum surface the
driver exposes) and returns null for unsupported aggregates so the handler
can surface `BadAggregateNotSupported`. Raw+Processed+AtTime wrap driver
samples as `HistoryData` in an `ExtensionObject`; Events emits a
`HistoryEvent` with the standard BaseEventType field list (EventId /
SourceName / Message / Severity / Time / ReceiveTime) — custom
`SelectClause` evaluation is an explicit follow-up.
**Tests**:
- `DriverNodeManagerHistoryMappingTests` — 12 unit cases pinning
`MapAggregate`, `BuildHistoryData`, `BuildHistoryEvent`, `ToDataValue`.
- `HistoryReadIntegrationTests` — 5 end-to-end cases drive a real OPC UA
client (`Session.HistoryRead`) against a fake `IHistoryProvider` driver
through the running stack. Covers raw round-trip, processed with Average
aggregate, unsupported aggregate → `BadAggregateNotSupported`, at-time
timestamp forwarding, and events field-list shape.
**Deferred**:
- Continuation-point plumbing via `Session.Save/RestoreHistoryContinuationPoint`.
Driver returns null continuations today so the pass-through is fine.
- Per-`SelectClause` evaluation in HistoryReadEvents — clients that send a
custom field selection currently get the standard BaseEventType layout.
## 2. Write-gating by role — **DONE (PR 26)**
@@ -99,14 +125,29 @@ 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).
**Remaining**:
- Install + run the `OtOpcUaGalaxyHost` + `OtOpcUa` services on the dev box
(`scripts/install/Install-Services.ps1`) so the skip-on-unready tests
actually execute and the smoke PR lands green.
- Subscribe-and-receive-data-change fact (needs a known tag that actually
ticks; deferred until operators confirm a scratch tag exists).
- Write-and-roundtrip fact (needs a test-only UDA or agreed scratch tag
so we can't accidentally mutate a process-critical value).
**PR 40** added the write + subscribe facts targeting
`DelmiaReceiver_001.TestAttribute` (the writable Boolean UDA the dev Galaxy
ships under TestMachine_001) — write-then-read with a 5s scan-window poll +
restore-on-finally, and subscribe-then-write asserting both an initial-value
OnDataChange and a post-write OnDataChange. PR 39 added the elevated-shell
short-circuit so a developer running from an admin window gets an actionable
skip instead of `UnauthorizedAccessException`.
**Run the live tests** (from a NORMAL non-admin PowerShell):
```powershell
$env:OTOPCUA_GALAXY_SECRET = Get-Content C:\Users\dohertj2\Desktop\lmxopcua\.local\galaxy-host-secret.txt
cd C:\Users\dohertj2\Desktop\lmxopcua
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests --filter "FullyQualifiedName~LiveStackSmokeTests"
```
Expected: 7/7 pass against the running `OtOpcUaGalaxyHost` service.
**Remaining for #5 in production-grade form**:
- Confirm the suite passes from a non-elevated shell (operator action).
- Add similar facts for an alarm-source attribute once `TestMachine_001` (or
a sibling) carries a deployed alarm condition — the current dev Galaxy's
TestAttribute isn't alarm-flagged.
## 6. Second driver instance on the same server — **DONE (PR 32)**

View File

@@ -32,36 +32,34 @@ test project):
## 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

View File

@@ -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;
}
}

View File

@@ -1,3 +1,6 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Security.Principal;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
@@ -40,6 +43,25 @@ public sealed class LiveStackFixture : IAsyncLifetime
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(
@@ -111,6 +133,28 @@ public sealed class LiveStackFixture : IAsyncLifetime
{
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)]

View File

@@ -117,6 +117,141 @@ public sealed class LiveStackSmokeTests(LiveStackFixture fixture)
$"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

View File

@@ -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);
}
}

View File

@@ -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));
}
}
}