Compare commits

...

46 Commits

Author SHA1 Message Date
Joseph Doherty
463c5a4320 Phase 3 PR 48 -- DL205 CDAB word order for Float32 end-to-end test. The driver has supported ModbusByteOrder.WordSwap (CDAB) since PR 24 for all multi-register types -- the underlying word-swap code path was already there. PR 48 closes the loop with an integration test that validates it end-to-end against the dl205 pymodbus profile: HR[1056..1057] stores IEEE-754 1.5f with the low word at the lower address (0x0000 at HR[1056], 0x3FC0 at HR[1057]). Reading with WordSwap returns 1.5f; reading with BigEndian returns a tiny denormal (~5.74e-41) -- a silent "value is 0" bug that typically surfaces in the field only when an operator notices a setpoint readout stuck at 0 while the PLC display shows the real value. Test asserts both: WordSwap==1.5f AND BigEndian!=1.5f, proving the flag is not a no-op. No driver code changes -- the word-swap normalization at NormalizeWordOrder() has handled Float32/Int32/UInt32 correctly since PR 24 and the unit test suite already covers it (Int32_WordSwap_decodes_CDAB_layout + Float32 equivalent). This PR exists primarily to lock in the integration-level validation so future refactors of the codec don't silently break DL205/DL260 floats. 6/6 DL205 integration tests pass with MODBUS_SIM_PROFILE=dl205. 2026-04-18 21:51:15 -04:00
Joseph Doherty
2b5222f5db Phase 3 PR 47 -- DL205 V-memory octal-address helper. Adds DirectLogicAddress static class with two entry points: UserVMemoryToPdu(string) parses a DirectLOGIC V-address (V-prefixed or bare, whitespace tolerated) as OCTAL and returns the 0-based Modbus PDU address. V2000 octal = decimal 1024 = PDU 0x0400, which is the canonical start of the user V-memory bank on DL205/DL260. SystemVMemoryBasePdu + SystemVMemoryToPdu(ushort offset) handle the system bank (V40400 and up) which does NOT follow the simple octal-to-decimal formula -- the CPU relocates the system bank to PDU 0x2100 in H2-ECOM100 absolute mode. A naive caller converting 40400 octal would land at PDU 0x4100 (decimal 16640) and miss the system registers entirely; the helper routes the correct 0x2100 base. Why this matters: DirectLOGIC operators think in OCTAL (the ladder-logic editor, the Productivity/Do-more UI, every AutomationDirect manual addresses V-memory octally) while the Modbus wire is DECIMAL. Integrators routinely copy V-addresses from the PLC documentation into client configs and read garbage because they treated V2000 as decimal 2000 (HR[2000] = 0 in the dl205 sim, zero in most PLCs). The helper makes the translation explicit per the D2-USER-M appendix + H2-ECOM-M \u00A76.5 references cited in docs/v2/dl205.md. Unit tests: UserVMemoryToPdu_converts_octal_V_prefix (V0, V1, V7, V10, V2000, V7777, V10000, V17777 -- the exact sweep documented in dl205.md), UserVMemoryToPdu_accepts_bare_or_prefixed_or_padded (case + whitespace tolerance), UserVMemoryToPdu_rejects_non_octal_digits (V8/V19/V2009 must throw ArgumentException with 'octal' in the message -- .NET has no base-8 int.Parse so we hand-walk digits to catch 8/9 instead of silently accepting them), UserVMemoryToPdu_rejects_empty_input, UserVMemoryToPdu_overflow_rejected (200000 octal = 0x10000 overflows ushort), SystemVMemoryBasePdu_is_0x2100_for_V40400, SystemVMemoryToPdu_offsets_within_bank, SystemVMemoryToPdu_rejects_overflow. 23/23 Modbus.Tests pass. Integration tests against dl205.json pymodbus profile: DL205_V2000_user_memory_resolves_to_PDU_0x0400_marker (reads HR[0x0400]=0x2000), DL205_V40400_system_memory_resolves_to_PDU_0x2100_marker (reads HR[0x2100]=0x4040). 5/5 DL205 integration tests pass. Caller opts into the helper per tag by calling DirectLogicAddress.UserVMemoryToPdu("V2000") as the ModbusTagDefinition Address -- no driver-wide "DL205 mode" flag needed, because users mix DL and non-DL tags in a single driver instance all the time. 2026-04-18 21:49:58 -04:00
Joseph Doherty
8248b126ce Phase 3 PR 46 -- DL205 BCD decoder (binary-coded-decimal numeric encoding). Adds ModbusDataType.Bcd16 and Bcd32 to the driver. Bcd16 is 1 register wide, Bcd32 is 2 registers wide; Bcd32 respects ModbusByteOrder (BigEndian/WordSwap) the same way Int32 does so the CDAB-style families (including DL205/DL260 themselves) can be configured. DecodeRegister uses the new internal DecodeBcd helper: walks each nibble from MSB to LSB, multiplies the running result by 10, adds the nibble as a decimal digit. Explicitly rejects nibbles > 9 with InvalidDataException -- hardware sometimes produces garbage during write-in-progress transitions and silently returning wrong numeric values would quietly corrupt the caller's data. EncodeRegister's new EncodeBcd inverts the operation (mod/div by 10 nibble-by-nibble) with an up-front overflow check against 10^nibbles-1. Why this matters for DL205/DL260: AutomationDirect DirectLOGIC uses BCD as the default numeric encoding for timers, counters, and operator-display numerics (not binary). A plain Int16 read of register 0x1234 returns 4660; the BCD path returns 1234. The two differ enough that silently defaulting to Int16 would give wildly wrong HMI values -- the caller must opt in to Bcd16/Bcd32 per tag. Unit tests: DecodeBcd (theory: 0,1,9,10,1234,9999), DecodeBcd_rejects_nibbles_above_nine, EncodeBcd (theory), Bcd16_decodes_DL205_register_1234_as_decimal_1234 (control: same bytes as Int16 decode to 4660), Bcd16_encode_round_trips_with_decode, Bcd16_encode_rejects_out_of_range_values, Bcd32_decodes_8_digits_big_endian, Bcd32_word_swap_handles_CDAB_layout, Bcd32_encode_round_trips_with_decode, Bcd_RegisterCount_matches_underlying_width. 66/66 Modbus.Tests pass. Integration test: DL205BcdQuirkTests.DL205_BCD16_decodes_HR1072_as_decimal_1234 against dl205.json pymodbus profile (HR[1072]=0x1234). Asserts Bcd16 decode=1234 AND Int16 decode=0x1234 on the same wire bytes to prove the paths are distinct. 3/3 DL205 integration tests pass with MODBUS_SIM_PROFILE=dl205. 2026-04-18 21:46:25 -04:00
Joseph Doherty
cd19022d19 Phase 3 PR 45 -- DL205 string byte-order quirk (low-byte-first ASCII packing). Adds ModbusStringByteOrder enum {HighByteFirst, LowByteFirst} + StringByteOrder field on ModbusTagDefinition (default HighByteFirst, the standard Modbus convention). DecodeRegister + EncodeRegister String branches now respect per-tag byte order. Under LowByteFirst each register packs the first char in the low byte instead of the high byte -- the AutomationDirect DirectLOGIC DL205/DL260/DL350 family's headline string quirk. Without the flag the driver decodes 'eHllo' garbage from HR[1040..1042] even though wire bytes are identical. Unit tests: String_LowByteFirst_decodes_DL205_packed_Hello (5 chars across 3 regs with nul pad), String_LowByteFirst_decode_truncates_at_first_nul, String_LowByteFirst_encode_round_trips_with_decode (asserts exact DL205-documented byte sequence {0x65,0x48,0x6C,0x6C,0x00,0x6F} + symmetric encode->decode), String_HighByteFirst_and_LowByteFirst_differ_on_same_wire (control: same wire, different flag => different decode). 56/56 Modbus.Tests pass. Integration test: DL205StringQuirkTests.DL205_string_low_byte_first_decodes_Hello_from_HR1040 against the dl205.json pymodbus profile; reads HR[1040..1042] with both flags on the same tag map and asserts LowByteFirst='Hello' + HighByteFirst!='Hello'. Gated on MODBUS_SIM_PROFILE=dl205 since the standard profile doesn't seed HR[1040..1042]. Verified 2/2 integration tests pass against running pymodbus dl205 simulator. Baseline for PR 46 (BCD decoder), PR 47 (V-memory octal helper), PR 48 (CDAB float order), PR 49 (FC03/FC16 per-device caps) -- each lands its own DL205_<behavior> test class in tests/.../DL205/. 2026-04-18 21:43:32 -04:00
5ee9acb255 Merge pull request 'Phase 3 PR 44 -- pymodbus validation + IPv4-explicit transport bugfix' (#43) from phase-3-pr44-pymodbus-validation-fixes into v2 2026-04-18 21:39:24 -04:00
Joseph Doherty
02fccbc762 Phase 3 PR 43 — followup commit: validate pymodbus simulator end-to-end + fix three real bugs surfaced by running it. winget-installed Python 3.12.10 + pip-installed pymodbus[simulator]==3.13.0 on the dev box; both profiles boot cleanly, the integration-suite smoke test passes against either profile.
Three substantive issues caught + fixed during the validation pass:
1. pymodbus rejects unknown keys at device-list / setup level. My PR 43 commit had `_layout_note`, `_uint16_layout`, `_bits_layout`, `_write_note` device-level JSON-comment fields that crashed pymodbus startup with `INVALID key in setup`. Removed all device-level _* fields. Inline `_quirk` keys WITHIN individual register entries are tolerated by pymodbus 3.13.0 — kept those in dl205.json since they document the byte math per quirk and the README + git history aren't enough context for a hand-author reading raw integer values. Documented the constraint in the top-level _comment of each profile.
2. pymodbus rejects sweeping `write` ranges that include any cell not assigned a type. My initial standard.json had `write: [[0, 2047]]` but only seeded HR[0..31] + HR[100] + HR[200..209] + bits[1024..1109] — pymodbus blew up on cell 32 (gap between HR[31] and HR[100]). Fixed by listing per-block write ranges that exactly mirror the seeded ranges. Same fix in dl205.json (was `[[0, 16383]]`).
3. pymodbus simulator stores all 4 standard Modbus tables in ONE underlying cell array — each cell can only be typed once (BITS or UINT16, not both). My initial standard.json had `bits[0..31]` AND `uint16[0..31]` overlapping at the same addresses; pymodbus crashed with `ERROR "uint16" <Cell> used`. Fixed by relocating coils to address 1024+, well clear of the uint16 entries at 0..209. Documented the layout constraint in the standard.json top-level _comment.
Substantive driver bug fixed: ModbusTcpTransport.ConnectAsync was using `new TcpClient()` (default constructor — dual-stack, IPv6 first) then `ConnectAsync(host, port)` with the user's hostname. .NET's TcpClient default-resolves "localhost" to ::1 first, fails to connect to pymodbus (which binds 0.0.0.0 IPv4-only), and only then retries IPv4 — the failure surfaces as the entire ConnectAsync timeout (2s by default) before the IPv4 attempt even starts. PR 30's smoke test silently SKIPPED because the fixture's TCP probe hit the same dual-stack ordering and timed out. Both fixed: ModbusSimulatorFixture probe now resolves Dns.GetHostAddresses, prefers AddressFamily.InterNetwork, dials IPv4 explicitly. ModbusTcpTransport does the same — resolves first, prefers IPv4, falls back to whatever Dns returns (handles IPv6-only hosts in the future). This is a real production-readiness fix because most Modbus PLCs are IPv4-only — a generic dual-stack TcpClient would burn the entire connect timeout against any IPv4-only PLC, masquerading as a connection failure when the PLC is actually fine.
Smoke-test address shifted HR[100] -> HR[200]. Standard.json's HR[100] is the auto-incrementing register that drives subscribe-and-receive tests, so write-then-read against it would race the increment. HR[200] is the first cell of a writable scratch range present in BOTH simulator profiles. DL205Profile.cs xml-doc updated to explain the shift; tag name "DL205_Smoke_HReg100" -> "Smoke_HReg200" + smoke test references updated. dl205.json gains a matching scratch HR[200..209] range so the smoke test runs identically against either profile.
Validation matrix:
- standard.json boot: clean (TCP 5020 listening within ~3s of pymodbus.simulator launch).
- dl205.json boot: clean.
- pymodbus client direct FC06 to HR[200]=1234 + FC03 read: round-trip OK.
- raw-bytes PowerShell TcpClient FC06 + 12-byte response: matches FC06 spec (echo of address + value).
- DL205SmokeTest against standard.json: 1/1 pass (was failing as 'BadInternalError' due to the dual-stack timeout + tag-name typo — both fixed).
- DL205SmokeTest against dl205.json: 1/1 pass.
- Modbus.Tests Unit suite: 52/52 pass — dual-stack transport fix is non-breaking.
- Solution build clean.
Memory + future-PR setup: pymodbus install + activation pattern is now bullet-pointed at the top of Pymodbus/README.md so future PRs (the per-quirk DL205_<behavior> tests in PR 44+) don't have to repeat the trial-and-error of getting the simulator + integration tests cooperating. The three bugs above are documented inline in the JSON profiles + ModbusTcpTransport so they don't bite again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:14:02 -04:00
faeab34541 Merge pull request 'Phase 3 PR 43 — Swap ModbusPal to pymodbus for the integration-test simulator' (#42) from phase-3-pr43-pymodbus-swap into v2 2026-04-18 20:52:46 -04:00
Joseph Doherty
a05b84858d Phase 3 PR 43 — Swap ModbusPal to pymodbus for the integration-test simulator. Replaces the .xmpp profiles shipped in PR 42 with pymodbus 3.13.0 ModbusSimulatorServer JSON configs in tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/. Substantive reasons for the swap (rationale block in the test-plan doc): ModbusPal 1.6b is abandoned (last release ~2019), Java GUI-only with no headless mode in the official JAR, and only exposes 2 of the 4 standard Modbus tables (holding_registers + coils — no input_registers, no discrete_inputs). pymodbus is current stable, pure Python CLI (pip install pymodbus[simulator]==3.13.0), exposes all four tables, has built-in declarative actions (increment / random / timestamp / uptime) for dynamic registers, supports custom Python actions for anything more complex, and ships an optional aiohttp-based web UI / REST API for live inspection. Pip-installable on Windows; sidesteps the privileged-port admin requirement by defaulting to TCP 5020.
ModbusSimulatorFixture default port bumped from 502 to 5020 to match the pymodbus convention. Override via MODBUS_SIM_ENDPOINT for a real PLC on its native 502. Skip-message updated to point at the new Pymodbus\serve.ps1 wrapper instead of 'start ModbusPal'. csproj <None Update> rule swapped from ModbusPal/** to Pymodbus/** so the new JSON profiles + serve.ps1 + README copy to test-output as PreserveNewest.
standard.json — generic Modbus TCP server, slave id 1, port 5020, shared blocks=false (independent coils + HR address spaces, more textbook-PLC-like). HR[0..31] seeded with address-as-value via per-register uint16 entries, HR[100] auto-increments via the built-in increment action with parameters minval=0/maxval=65535 (drives subscribe-and-receive integration tests so they have a register that ticks without a write — pymodbus's increment ticks per-access not wall-clock, which is good enough for a 250ms-poll test), HR[200..209] scratch range left at 0 for write tests, coils 0..31 alternating, coils 100..109 scratch. write list covers 0..1023 so any test address is mutable.
dl205.json — AutomationDirect DirectLOGIC DL205/DL260 quirk simulator, slave id 1, port 5020, shared blocks=true (matches DL series memory model where coils/DI/HR overlay the same word address space). Each quirky register seeded with the pre-computed raw uint16 value documented in docs/v2/dl205.md, with an inline _quirk JSON-comment naming the behavior so future-me reading the file knows why HR[1040]=25928 means 'H' lo / 'e' hi (the user's headline string-byte-order finding). Encoded quirks: V0 marker at HR[0]=0xCAFE; V2000 at HR[1024]=0x2000; V40400 at HR[8448]=0x4040; 'Hello' string at HR[1040..1042] first-char-low-byte; Float32 1.5f at HR[1056..1057] in CDAB word order (low word first); BCD register at HR[1072]=0x1234; FC03-128-cap block at HR[1280..1407]; Y0/C0 coil markers at 2048/3072; scratch C-relays at 4000..4007.
serve.ps1 wrapper — pwsh script with a -Profile {standard|dl205} parameter switch. Validates pymodbus.simulator is on PATH (clearer message than the raw CommandNotFoundException), validates the profile JSON exists, builds the right --modbus_server/--modbus_device/--json_file/--http_port arg list, and execs pymodbus.simulator in the foreground. -HttpPort 0 disables the web UI. Foreground exec lets the operator Ctrl+C to stop without an extra control script.
README.md fully rewritten for pymodbus: install command (pip install 'pymodbus[simulator]==3.13.0' — pinned for reproducibility, [simulator] extra pulls aiohttp), per-profile reference tables, the same DL205 quirk → register table from PR 42 but adjusted for pymodbus paths, what's-NEW-vs-ModbusPal section (all four tables, raw uint16 seeding, declarative actions, custom Python action modules, headless, web UI, maintained), trade-offs section (float32-as-two-uint16s for explicit CDAB control, increment ticks per-access not wall-clock, shared-blocks mode for DL205 vs separate for Standard), file-format quick reference for hand-authoring more profiles. References pinned to the pymodbus readthedocs simulator/config + REST API pages.
docs/v2/modbus-test-plan.md harness section rewritten with the swap rationale; PR-history list updated to mark PR 42 SUPERSEDED by PR 43 and call out PR 44+ as the per-quirk implementation track. Test-conventions bullet about 'don't depend on ModbusPal state between tests' generalized to 'don't depend on simulator state' and a note added that pymodbus's REST API can reset state between facts if a test ever needs it.
DL205Profile.cs and DL205SmokeTests.cs xml-doc updated to reference pymodbus / dl205.json instead of ModbusPal / DL205.xmpp.
Functional validation deferred — Python isn't installed on this dev box (winget search returned no matches for Python.Python.3 exact). JSON parses structurally (PowerShell ConvertFrom-Json clean on both files), build clean, .json + serve.ps1 + README all copy to test-output as expected. User installs pymodbus when they want to actually run the simulator end-to-end; if pymodbus rejects the config the README's reference link to pymodbus's simulator/config schema doc is the right next stop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:35:26 -04:00
c59ac9e52d Merge pull request 'Phase 3 PR 42 — ModbusPal simulator profiles for Standard + DL205/DL260' (#41) from phase-3-pr42-modbuspal-profiles into v2 2026-04-18 20:12:39 -04:00
Joseph Doherty
02a0e8efd1 Phase 3 PR 42 — ModbusPal simulator profiles for Standard Modbus + DL205/DL260 quirks. Two hand-authored .xmpp profiles in tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusPal/ that integration tests load via the GUI to drive the suite without a real PLC. Both well-formed XML (verified via PowerShell [xml] cast); both copied to test-output as PreserveNewest content per the existing csproj rule.
Standard.xmpp — generic Modbus TCP server on port 502, slave id 1. HR[0..31] seeded with address-as-value (HR[5]=5 — easy mental map for diagnostics), HR[100] auto-incrementing via a 1Hz LinearGenerator binding (drives subscribe-and-receive integration tests so they have a register that actually changes without a write), HR[200..209] scratch range for write-roundtrip tests, coils 0..31 alternating on/off, coils 100..109 scratch. The Tick automation runs 0..65535 over 60s looping; bound to HR[100] via Binding_SINT16 — slow enough that a 250ms-poll integration test sees discrete jumps, fast enough that a 5s subscribe test sees several change notifications.
DL205.xmpp — AutomationDirect DirectLOGIC DL205/DL260 quirk simulator on port 502, slave id 1, modeling the behaviors documented in docs/v2/dl205.md as concrete register values so DL205 integration tests can assert each quirk WITHOUT a live PLC. Per-quirk encoding: V0 marker at HR[0]=0xCAFE proves register 0 is valid (rejects-register-0 rumour disproved); V2000 marker at HR[1024]=0x2000 proves V-memory octal-to-decimal mapping; V40400 marker at HR[8448]=0x4040 proves V40400→PDU 0x2100 (NOT register 0, contrary to the widespread shorthand); 'Hello' string at HR[1040..1042] packed first-char-low-byte (HR[1040]=0x6548 = 'H' lo + 'e' hi, HR[1041]=0x6C6C, HR[1042]=0x006F) — the headline string-byte-order quirk the user flagged; Float32 1.5f at HR[1056..1057] in CDAB word order (low word first: 0, then 0x3FC0); BCD register at HR[1072]=0x1234 representing decimal 1234 in BCD nibbles (NOT binary 0x04D2); 128-register block at HR[1280..1407] for FC03-128-cap testing; Y0 marker at coil 2048, C0 marker at coil 3072, scratch C-coils at 4000..4007 for write tests.
Critical limitation flagged inline + in README: ModbusPal 1.6b CANNOT represent the DL205 quirks semantically — it has no string binding, no BCD binding, no arbitrary-byte-layout binding (only SINT16/SINT32/FLOAT32 with word-order). So every DL205 quirk is encoded as a pre-computed raw 16-bit integer with the math worked out in inline comments above each register. Becomes unreadable past ~50 quirky registers; the README's 'alternatives' section recommends switching to pymodbus when that threshold approaches (pymodbus's ModbusSimulatorServer has first-class headless + scriptable callbacks for byte-level layouts).
Other ModbusPal 1.6b limitations called out in README: only holding_registers + coils sections in the official build (no input_registers / discrete_inputs — DL260 X-input markers can't be encoded faithfully here, FC02/FC04 tests wait for a fork or pymodbus); abandoned project (last release 1.6b, active forks at SCADA-LTS/ModbusPal, ControlThings-io/modbuspal, mrhenrike/ModbusPalEnhanced); no headless mode in the official JAR (-loadFile / -hide flags only in source-built forks); CVE-2018-10832 XXE on .xmpp import (don't import untrusted profiles — the in-repo ones are author-controlled).
README.md updated with: per-profile description tables, getting-started (download jar + java -jar + GUI File>Load>Run), MODBUS_SIM_ENDPOINT env-var override doc, two reference tables documenting which HR / coil address encodes which DL205 quirk + which test name asserts it (the same DL205_<behavior> naming convention from docs/v2/modbus-test-plan.md), 4-row alternatives comparison (pymodbus / diagslave / ModbusMechanic / ModRSsim2) for when ModbusPal can no longer carry the load, and a quick-reference XML format table at the bottom for future-me hand-authoring more profiles.
Pure documentation + test-asset PR — no code changes. The integration tests that consume these profiles (the actual DL205_<behavior> facts) land one at a time in PR 43+ as user validates each quirk via ModbusPal on the bench.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:05:20 -04:00
7009483d16 Merge pull request 'Phase 3 PR 41 — Document AutomationDirect DL205 / DL260 Modbus quirks' (#40) from phase-3-pr41-dl205-quirks-doc into v2 2026-04-18 19:52:20 -04:00
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
Joseph Doherty
8adc8f5ab8 Phase 3 PR 37 — End-to-end live-stack Galaxy smoke test. Closes the code side of LMX follow-up #5; once OtOpcUaGalaxyHost is installed + started on the dev box, the suite exercises the full topology GalaxyProxyDriver in-process → named-pipe IPC → running OtOpcUaGalaxyHost Windows service → MxAccessGalaxyBackend → live MXAccess runtime → real deployed Galaxy objects. Never spawns the Host process itself — connects to the already-running service per project_galaxy_host_service.md, which is the only way to exercise the production COM-apartment + service-account + pipe-ACL configuration.
LiveStackConfig resolves the pipe name + per-install shared secret from two sources in order: OTOPCUA_GALAXY_PIPE + OTOPCUA_GALAXY_SECRET env vars first (for CI / benchwork overrides), then the service's per-process Environment registry values under HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost (what Install-Services.ps1 writes at install time). Registry read requires the test host to run elevated on most boxes — the skip message says so explicitly so operators see the right remediation. Hard-coded secrets are deliberately avoided: the installer generates 32 fresh random bytes per install, a committed secret would diverge from production the moment the service is re-installed.
LiveStackFixture is an IAsyncLifetime that (1) runs AvevaPrerequisites.CheckAllAsync with CheckGalaxyHostPipe=true + CheckHistorian=false — produces a structured PrerequisiteReport whose SkipReason is the exact operator-facing 'here's what you need to fix' text, (2) resolves LiveStackConfig and surfaces a clear skip when the secret isn't discoverable, (3) instantiates GalaxyProxyDriver + calls InitializeAsync (the IPC handshake), capturing a skip with the exception detail + common-cause hints (secret mismatch, SID not in pipe ACL, Host's backend couldn't connect to ZB) rather than letting a NullRef cascade through every subsequent test. SkipIfUnavailable() translates the captured SkipReason into Assert.Skip at the top of every fact so tests read as cleanly-skipped with a visible reason, not silently-passed or crashed.
LiveStackSmokeTests (5 facts, Collection=LiveStack, Category=LiveGalaxy): Fixture_initialized_successfully (cheapest possible end-to-end assertion — if this passes, the IPC handshake worked); Driver_reports_Healthy_after_IPC_handshake (DriverHealth.State post-connect); DiscoverAsync_returns_at_least_one_variable_from_live_galaxy (captures every Variable() call from DiscoverAsync via CapturingAddressSpaceBuilder and asserts > 0 — zero here usually means the Host couldn't read ZB, the skip message names OTOPCUA_GALAXY_ZB_CONN to check); GetHostStatuses_reports_at_least_one_platform (IHostConnectivityProbe surface — zero means the probe loop hasn't fired or no Platform is deployed locally); Can_read_a_discovered_variable_from_live_galaxy (reads the first discovered attribute's full reference, asserts status != BadInternalError — Galaxy's Uncertain-quality-until-first-Engine-scan is intentionally NOT treated as failure since it depends on runtime state that varies across test runs). Read-only by design; writes need an agreed scratch tag to avoid mutating a process-critical attribute — deferred to a follow-up PR that reuses this fixture.
CapturingAddressSpaceBuilder is a minimal IAddressSpaceBuilder that flattens every Variable() call into a list so tests can inspect what discovery produced without booting the full OPC UA node-manager stack; alarm annotation + property calls are no-ops. Scoped private to the test class.
Galaxy.Proxy.Tests csproj gains a ProjectReference to Driver.Galaxy.TestSupport (PR 36) for AvevaPrerequisites. The NU1702 warning about the Host project being net48-referenced-by-net10 is pre-existing from the HostSubprocessParityTests — Proxy.Tests only needs the Host EXE path for that parity scenario, not type surface.
Test run on THIS machine (OtOpcUaGalaxyHost not yet installed): Skipped! Failed 0, Passed 0, Skipped 5 — each skip message includes the full prerequisites report pointing at the missing service. Once the service is installed + started (scripts\install\Install-Services.ps1), the 5 facts will execute against live Galaxy. Proxy.Tests Unit: 17 pass / 0 fail (unchanged — new tests are Category=LiveGalaxy, separate suite). Full Proxy build clean. Memory already captures the 'live tests run via already-running service, don't spawn' convention (project_galaxy_host_service.md).
lmx-followups.md #5 updated: status is 'IN PROGRESS' across PRs 36 + 37 with the explicit remaining work (install + start services, subscribe-and-receive, write round-trip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:49:51 -04:00
261869d84e Merge pull request 'Phase 3 PR 36 — AVEVA prerequisites test-support library' (#35) from phase-3-pr36-aveva-prerequisites into v2 2026-04-18 16:44:41 -04:00
Joseph Doherty
08c90d19fd Phase 3 PR 36 — AVEVA prerequisites test-support library. New tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport multi-targeted class library (net10.0 + net48 so both the modern and the MXAccess-COM x86 test projects can consume it) that probes every piece of the AVEVA System Platform + OtOpcUa stack a live-Galaxy test depends on and returns a structured PrerequisiteReport. Closes the gap where live-smoke tests silently returned 'unreachable' without telling operators which specific piece failed.
AvevaPrerequisites.CheckAllAsync walks eight probe categories producing PrerequisiteCheck rows each with Name (e.g. 'service:aaBootstrap', 'sql:ZB', 'com:LMXProxy', 'registry:ArchestrA.Framework'), Category (AvevaCoreService / AvevaSoftService / AvevaInstall / MxAccessCom / GalaxyRepository / AvevaHistorian / OtOpcUaService / Environment), Status (Pass / Warn / Fail / Skip), and operator-facing Detail message. Report aggregates them: IsLivetestReady (no Fails anywhere) and IsAvevaSideReady (AVEVA-side categories pass, our v2 services can be absent while still considering the environment AVEVA-ready) so different test tiers can use the right threshold.
Individual probes: ServiceProbe.Check queries the Windows Service Control Manager via System.ServiceProcess.ServiceController — treats DemandStart+Stopped as Warn (NmxSvc is DemandStart by design; master pulls it up) but AutoStart+Stopped as Fail; not-installed is Fail for hard-required services, Warn for soft ones; non-Windows hosts get Skip; transitional states like StartPending get Warn with a 'try again' hint. RegistryProbe reads HKLM\SOFTWARE\WOW6432Node\ArchestrA\{Framework,Framework\Platform,MSIInstall} — Framework key presence + populated InstallPath/RootPath values mean System Platform installed; PfeConfigOptions in the Platform subkey (format 'PlatformId=N,EngineId=N,...') indicates a Platform has been deployed from the IDE (PlatformId=0 means never deployed — MXAccess will connect but every subscription will be Bad quality); RebootRequired='True' under MSIInstall surfaces as a loud warn since post-patch behavior is undefined. MxAccessComProbe resolves the LMXProxy.LMXProxyServer ProgID → CLSID → HKLM\SOFTWARE\Classes\WOW6432Node\CLSID\{guid}\InprocServer32, verifying the registered file exists on disk (catches the orphan-registry case where a previous uninstall left the ProgID registered but the DLL is gone — distinguishes it from the 'totally not installed' case by message); also emits a Warn when the test process is 64-bit (MXAccess COM activation fails with REGDB_E_CLASSNOTREG 0x80040154 regardless of registration, so seeing this warning tells operators why the activation would fail even on a fully-installed machine). SqlProbe tests Galaxy Repository via Microsoft.Data.SqlClient using the Windows-auth localhost connection string the repo code defaults to — distinguishes 'SQL Server unreachable' (connection fails) from 'ZB database does not exist' (SELECT DB_ID('ZB') returns null) because they have different remediation paths (sc.exe start MSSQLSERVER vs. restore from .cab backup); a secondary CheckDeployedObjectCountAsync query on 'gobject WHERE deployed_version > 0' warns when the count is zero because discovery smoke tests will return empty hierarchies. NamedPipeProbe opens a 2s NamedPipeClientStream against OtOpcUaGalaxyHost's pipe ('OtOpcUaGalaxy' per the installer default) — pipe accepting a connection proves the Host service is listening; disconnects immediately so we don't consume a session slot.
Service lists kept as internal static data so tests can inspect + override: CoreServices (aaBootstrap + aaGR + NmxSvc + MSSQLSERVER — hard fail if missing), SoftServices (aaLogger + aaUserValidator + aaGlobalDataCacheMonitorSvr — warn only; stack runs without them but diagnostics/auth are degraded), HistorianServices (aahClientAccessPoint + aahGateway — opt-in via Options.CheckHistorian, only matters for HistoryRead IPC paths), OtOpcUaServices (our OtOpcUaGalaxyHost hard-required for end-to-end live tests + OtOpcUa warn + GLAuth warn). Narrower entry points CheckRepositoryOnlyAsync and CheckGalaxyHostPipeOnlyAsync for tests that only care about specific subsystems — avoid paying the full probe cost on every GalaxyRepositoryLiveSmokeTests fact.
Multi-targeting mechanics: System.ServiceProcess.ServiceController + Microsoft.Win32.Registry are NuGet packages on net10 but in-box BCL references on net48; csproj conditions Package vs Reference by TargetFramework. Microsoft.Data.SqlClient v6 supports both frameworks so single PackageReference. Net48Polyfills.cs provides IsExternalInit shim (records/init-only setters) and SupportedOSPlatformAttribute stub so the same Probe sources compile on both frameworks without per-callsite preprocessor guards — lets Roslyn's platform-compatibility analyzer stay useful on net10 without breaking net48 builds.
Existing GalaxyRepositoryLiveSmokeTests updated to delegate its skip decision to AvevaPrerequisites.CheckRepositoryOnlyAsync (legacy ZbReachableAsync kept as a compatibility adapter so the in-test 'if (!await ZbReachableAsync()) return;' pattern keeps working while the surrounding fixtures gradually migrate to Assert.Skip-with-reason). Slnx file registers the new project.
Tests — AvevaPrerequisitesLiveTests (8 new Integration cases, Category=LiveGalaxy): the helper correctly reports Framework install (registry pass), aaBootstrap Running (service pass), aaGR Running (service pass), MxAccess COM registered (com pass), ZB database reachable (sql pass), deployed-object count > 0 (warn-upgraded-to-pass because this box has 49 objects deployed), the AVEVA side is ready even when our own services (OtOpcUaGalaxyHost) aren't installed yet (IsAvevaSideReady=true), and the helper emits rows for OtOpcUaGalaxyHost + OtOpcUa + GLAuth even when not installed (regression guard — nobody can accidentally ship a check that omits our own services). Full Galaxy.Host.Tests Category=LiveGalaxy suite: 13 pass (5 prior smoke + 8 new prerequisites). Full solution build clean, 0 errors.
What's NOT in this PR: end-to-end Galaxy stack smoke (Proxy → Host pipe → MXAccess → real Galaxy tag). That's the next PR — this one is the gate the end-to-end smoke will call first to produce actionable skip messages instead of silent returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:36:13 -04:00
5cc120d836 Merge pull request 'Phase 3 PR 35 — IHistoryProvider gains ReadAtTime + ReadEvents; Proxy implements both' (#34) from phase-3-pr35-history-readtime-readevents into v2 2026-04-18 16:12:43 -04:00
Joseph Doherty
bf329b05d8 Phase 3 PR 35 — IHistoryProvider gains ReadAtTimeAsync + ReadEventsAsync; GalaxyProxyDriver implements both. Extends Core.Abstractions.IHistoryProvider with two new methods that round out the OPC UA Part 11 HistoryRead surface (HistoryReadAtTime + HistoryReadEvents are the last two modes not covered by the PR 19-era ReadRawAsync + ReadProcessedAsync) and wires GalaxyProxyDriver to call the existing PR-10/PR-11 IPC contracts the Host already implements.
Interface additions use C# default interface implementations that throw NotSupportedException — existing IHistoryProvider implementations keep compiling, only drivers whose backend carries the relevant capability override. This matches the 'capabilities are optional per driver' design already used by IHistoryProvider.ReadProcessedAsync's docs (Modbus / OPC UA Client drivers never had an event historian and the default-throw path lets callers see BadHistoryOperationUnsupported naturally). New HistoricalEvent record models one historian row (EventId, SourceName, EventTimeUtc + ReceivedTimeUtc — process vs historian-persist timestamps, Message, Severity mapped to OPC UA's 1-1000 range); HistoricalEventsResult pairs the event list with a continuation-point token for future batching. Both live in Core.Abstractions so downstream (Proxy, Host, Server) reference a single domain shape — no Shared-contract leak into the driver-facing interface.
GalaxyProxyDriver.ReadAtTimeAsync maps the domain DateTime[] to Unix-ms longs, calls CallAsync on the existing MessageKind.HistoryReadAtTimeRequest, and trusts the Host's one-sample-per-requested-timestamp contract (the Host pads with bad-quality snapshots for timestamps it can't interpolate; re-aligning on the Proxy side would duplicate the Host's interpolation policy logic). ReadEventsAsync does the same for HistoryReadEventsRequest; ToHistoricalEvent translates GalaxyHistoricalEvent (MessagePack-annotated, Unix-ms) to the domain record, explicitly tagging DateTimeKind.Utc on both timestamp fields so downstream serializers (JSON, OPC UA types) don't apply an unexpected local-time offset.
Tests — HistoricalEventMappingTests (3 new Proxy.Tests unit cases): every field maps correctly from wire to domain; null SourceName and null DisplayText preserve through the mapping (system events without a source come out with null so callers can distinguish them from alarm events); both timestamps come out as DateTimeKind.Utc (regression guard against a future refactor using DateTime.FromFileTimeUtc or similar that defaults to Unspecified). Driver.Galaxy.Proxy.Tests Unit suite: 17 pass / 0 fail (14 prior + 3 new). Full solution build clean, 0 errors.
Scope exclusions — DriverNodeManager HistoryRead service-handler wiring (on the OPC UA Server side, where HistoryReadAtTime and HistoryReadEvents service requests land) and the full-loop integration test (OPC UA client → server → IPC → Host → HistorianDataSource → back) are deferred to a focused follow-up PR. The capability surface is the load-bearing change; wiring the service handlers is mechanical in comparison and worth its own PR for reviewability. docs/v2/lmx-followups.md #1 updated with the split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:08:27 -04:00
2584379e75 Merge pull request 'Phase 3 PR 34 — Host-status publisher (Server) + /hosts drill-down page (Admin)' (#33) from phase-3-pr34-host-status-publisher-page into v2 2026-04-18 16:04:20 -04:00
Joseph Doherty
ef2a810b2d Phase 3 PR 34 — Host-status publisher (Server) + /hosts drill-down page (Admin). Closes LMX follow-up #7 by wiring together the data layer from PR 33. Server.HostStatusPublisher is a BackgroundService that walks every driver registered in DriverHost every 10 seconds, skips drivers that don't implement IHostConnectivityProbe, calls GetHostStatuses() on each probe-capable driver, and upserts one DriverHostStatus row per (NodeId, DriverInstanceId, HostName) into the central config DB. Upsert path: SingleOrDefaultAsync on the composite PK; if no row exists, Add a new one; if a row exists, LastSeenUtc advances unconditionally (heartbeat) and State + StateChangedUtc update only on transitions so Admin UI can distinguish 'still reporting, still Running' from 'freshly transitioned to Running'. MapState translates Core.Abstractions.HostState to Configuration.Enums.DriverHostState (intentional duplicate enum — Configuration project stays free of driver-runtime deps per PR 33's choice). If a driver's GetHostStatuses throws, log warning and skip that driver this tick — never take down the Server on a publisher failure. If the DB is unreachable, log warning + retry next heartbeat (no buffering — next tick's current-state snapshot is more useful than replaying stale transitions after a long outage). 2-second startup delay so NodeBootstrap's RegisterAsync calls land before the first publish tick, then tick runs immediately so a freshly-started Server surfaces its host topology in the Admin UI without waiting a full interval.
Polling chosen over event-driven for initial scope: simpler, matches Admin UI consumer cadence, avoids DriverHost lifecycle-event plumbing that doesn't exist today. Event-driven push for sub-heartbeat latency is a straightforward follow-up.
Admin.Services.HostStatusService left-joins DriverHostStatus against ClusterNode on NodeId so rows persist even when the ClusterNode entry doesn't exist yet (first-boot bootstrap case). StaleThreshold = 30s — covers one missed publisher heartbeat plus a generous buffer for clock skew and GC pauses. Admin Components/Pages/Hosts.razor — FleetAdmin-visible page grouped by cluster (handles the '(unassigned)' case for rows without a matching ClusterNode). Four summary cards (Hosts / Running / Stale / Faulted); per-cluster table with Node / Driver / Host / State + Stale-badge / Last-transition / Last-seen / Detail columns; 10s auto-refresh via IServiceScopeFactory timer pattern matching FleetStatusPoller + Fleet dashboard (PR 27). Row-class highlighting: Faulted → table-danger, Stale → table-warning, else default. State badge maps DriverHostState enum to bootstrap color classes. Sidebar link added between 'Fleet status' and 'Clusters'.
Server csproj adds Microsoft.EntityFrameworkCore.SqlServer 10.0.0 + registers OtOpcUaConfigDbContext in Program.cs scoped via NodeOptions.ConfigDbConnectionString (no Admin-style manual SQL raw — the DbContext is the only access path, keeps migrations owner-of-record).
Tests — HostStatusPublisherTests (4 new Integration cases, uses per-run throwaway DB matching the FleetStatusPollerTests pattern): publisher upserts one row per host from each probe-capable driver and skips non-probe drivers; second tick advances LastSeenUtc without creating duplicate rows (upsert pattern verified end-to-end); state change between ticks updates State AND StateChangedUtc (datetime2(3) rounds to millisecond precision so comparison uses 1ms tolerance — documented inline); MapState translates every HostState enum member. Server.Tests Integration: 4 new tests pass. Admin build clean, Admin.Tests Unit still 23 / 0. docs/v2/lmx-followups.md item #7 marked DONE with three explicit deferred items (event-driven push, failure-count column, SignalR fan-out).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:51:55 -04:00
a7764e50f3 Merge pull request 'Phase 3 PR 33 — DriverHostStatus entity + migration (LMX #7 data layer)' (#32) from phase-3-pr33-driverhoststatus-entity into v2 2026-04-18 15:43:37 -04:00
Joseph Doherty
8464e3f376 Phase 3 PR 33 — DriverHostStatus entity + EF migration (data-layer for LMX #7). New DriverHostStatus entity with composite key (NodeId, DriverInstanceId, HostName) persists each server node's per-host connectivity view — one row per (server node, driver instance, probe-reported host), which means a redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces 6 rows because each server node owns its own runtime view of the shared host topology, not 3. Fields: NodeId (64), DriverInstanceId (64), HostName (256 — fits Galaxy FQDNs and Modbus host:port strings), State (DriverHostState enum — Unknown/Running/Stopped/Faulted, persisted as nvarchar(16) via HasConversion<string> so DBAs inspecting the table see readable state names not ordinals), StateChangedUtc + LastSeenUtc (datetime2(3) — StateChangedUtc tracks actual transitions while LastSeenUtc advances on every publisher heartbeat so the Admin UI can flag stale rows from a crashed Server independent of State), Detail (nullable 1024 — exception message from the driver's probe when Faulted, null otherwise).
DriverHostState enum lives in Configuration.Enums/ rather than reusing Core.Abstractions.HostState so the Configuration project stays free of driver-runtime dependencies (it's referenced by both the Admin process and the Server process, so pulling in the driver-abstractions assembly to every Admin build would be unnecessary weight). The server-side publisher hosted service (follow-up PR 34) will translate HostStatusChangedEventArgs.NewState to this enum on every transition.
No foreign key to ClusterNode — a Server may start reporting host status before its ClusterNode row exists (first-boot bootstrap), and we'd rather keep the status row than drop it. The Admin-side service that renders the dashboard will left-join on NodeId when presenting. Two indexes declared: IX_DriverHostStatus_Node drives the per-cluster drill-down (Admin UI joins ClusterNode on ClusterId to pick which NodeIds to fetch), IX_DriverHostStatus_LastSeen drives the stale-row query (now - LastSeen > threshold).
EF migration AddDriverHostStatus creates the table + PK + both indexes. Model snapshot updated. SchemaComplianceTests expected-tables list extended. DriverHostStatusTests (3 new cases, category SchemaCompliance, uses the shared fixture DB): composite key allows same (host, driver) across different nodes AND same (node, host) across different drivers — both real-world cases the publisher needs to support; upsert-in-place pattern (fetch-by-composite-PK, mutate, save) produces one row not two — the pattern the publisher will use; State enum persists as string not int — reading the DB via ADO.NET returns 'Faulted' not '3'.
Configuration.Tests SchemaCompliance suite: 10 pass / 0 fail (7 prior + 3 new). Configuration build clean. No Server or Admin code changes yet — publisher + /hosts page are PR 34.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:38:41 -04:00
a9357600e7 Merge pull request 'Phase 3 PR 32 — Multi-driver integration test' (#31) from phase-3-pr32-multi-driver-integration into v2 2026-04-18 15:34:16 -04:00
Joseph Doherty
2f00c74bbb Phase 3 PR 32 — Multi-driver integration test. Closes LMX follow-up #6 with Server.Tests/MultipleDriverInstancesIntegrationTests.cs: registers two StubDriver instances (alpha + beta) with distinct DriverInstanceIds on one DriverHost, boots the full OpcUaApplicationHost, and exercises three behaviors end-to-end via a real OPC UA client session. (1) Each driver's namespace URI resolves to a distinct index in the client's NamespaceUris (alpha → urn:OtOpcUa:alpha, beta → urn:OtOpcUa:beta) — proves DriverNodeManager's namespaceUris-per-driver base-ctor wiring actually lands two separate INodeManager registrations. (2) Browsing one subtree returns only that driver's folder; the other driver's folder does NOT leak into the wrong subtree. This is the test that catches a cross-driver routing regression the v1 single-driver code path couldn't surface — if a future refactor flattens both drivers into a shared namespace, the 'shouldNotContain' assertion fails cleanly. (3) Reads route to the owning driver by namespace — alpha's ReadAsync returns 42 while beta's returns 99; a misroute would surface as 99 showing up on an alpha node id or vice versa. StubDriver is parameterized on (DriverInstanceId, folderName, readValue) so the same class constructs both instances without copy-paste.
No production code changes — pure additive test. Server.Tests Integration: 3 new tests pass; existing OpcUaServerIntegrationTests stays green (single-driver case still exercised there). Full Server.Tests Unit still 43 / 0. Deferred: multi-driver alarm-event case (two drivers each raising a GalaxyAlarmEvent, assert each condition lands on its owning instance's condition node) — needs a stub IAlarmSource and is worth its own focused PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:29:49 -04:00
5d5e1f9650 Merge pull request 'Phase 3 PR 31 — Live-LDAP integration test + Active Directory compatibility' (#30) from phase-3-pr31-live-ldap-ad-compat into v2 2026-04-18 15:27:54 -04:00
Joseph Doherty
4886a5783f Phase 3 PR 31 — Live-LDAP integration test + Active Directory compatibility. Closes LMX follow-up #4 with 6 live-bind tests in Server.Tests/LdapUserAuthenticatorLiveTests.cs against the dev GLAuth instance at localhost:3893 (skipped cleanly when unreachable via Assert.Skip + a clear SkipReason — matches the GalaxyRepositoryLiveSmokeTests pattern). Coverage: valid credentials bind + surface DisplayName; wrong password fails; unknown user fails; empty credentials fail pre-flight without touching the directory; writeop user's memberOf maps through GroupToRole to WriteOperate (the exact string WriteAuthzPolicy.IsAllowed expects); admin user surfaces all four mapped roles (WriteOperate + WriteTune + WriteConfigure + AlarmAck) proving memberOf parsing doesn't stop after the first match. While wiring this up, the authenticator's hard-coded user-lookup filter 'uid=<name>' didn't match GLAuth (which keys users by cn and doesn't populate uid) — AND it doesn't match Active Directory either, which uses sAMAccountName. Added UserNameAttribute to LdapOptions (default 'uid' for RFC 2307 backcompat) so deployments override to 'cn' / 'sAMAccountName' / 'userPrincipalName' as the directory requires; authenticator filter now interpolates the configured attribute. The default stays 'uid' so existing test fixtures and OpenLDAP installs keep working without a config change — a regression guard in LdapUserAuthenticatorAdCompatTests.LdapOptions_default_UserNameAttribute_is_uid_for_rfc2307_compat pins this so a future 'helpful' default change can't silently break anyone.
Active Directory compatibility. LdapOptions xml-doc expanded with a cheat-sheet covering Server (DC FQDN), Port 389 vs 636, UseTls=true under AD LDAP-signing enforcement, dedicated read-only service account DN, sAMAccountName vs userPrincipalName vs cn trade-offs, memberOf DN shape (CN=Group,OU=...,DC=... with the CN= RDN stripped to become the GroupToRole key), and the explicit 'nested groups NOT expanded' call-out (LDAP_MATCHING_RULE_IN_CHAIN / tokenGroups is a future authenticator enhancement, not a config change). docs/security.md §'Active Directory configuration' adds a complete appsettings.json snippet with realistic AD group names (OPCUA-Operators → WriteOperate, OPCUA-Engineers → WriteConfigure, OPCUA-AlarmAck → AlarmAck, OPCUA-Tuners → WriteTune), LDAPS port 636, TLS on, insecure-LDAP off, and operator-facing notes on each field. LdapUserAuthenticatorAdCompatTests (5 unit guards): ExtractFirstRdnValue parses AD-style 'CN=OPCUA-Operators,OU=...,DC=...' DNs correctly (case-preserving — operators' GroupToRole keys stay readable); also handles mixed case and spaces in group names ('Domain Users'); also works against the OpenLDAP ou=<group>,ou=groups shape (GLAuth) so one extractor tolerates both memberOf formats common in the field; EscapeLdapFilter escapes the RFC 4515 injection set (\, *, (, ), \0) so a malicious login like 'admin)(cn=*' can't break out of the filter; default UserNameAttribute regression guard.
Test posture — Server.Tests Unit: 43 pass / 0 fail (38 prior + 5 new AD-compat guards). Server.Tests LiveLdap category: 6 pass / 0 fail against running GLAuth (would skip cleanly without). Server build clean, 0 errors, 0 warnings.
Deferred: the session-identity end-to-end check (drive a full OPC UA UserName session, then read a 'whoami' node to verify the role landed on RoleBasedIdentity). That needs a test-only address-space node and is scoped for a separate PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:23:22 -04:00
d70a2e0077 Merge pull request 'Phase 3 PR 30 — Modbus integration-test project scaffold + DL205 smoke test' (#29) from phase-3-pr30-modbus-integration-scaffold into v2 2026-04-18 15:08:45 -04:00
Joseph Doherty
cb7b81a87a Phase 3 PR 30 — Modbus integration-test project scaffold. New tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests project is the harness modbus-test-plan.md called for: a skip-when-unreachable fixture that TCP-probes a Modbus simulator endpoint (MODBUS_SIM_ENDPOINT, default localhost:502) once per test session, a DL205 device profile stub (single writable holding register at address 100, probe disabled to avoid racing with assertions), and one happy-path smoke test that initializes the real ModbusDriver + real ModbusTcpTransport, writes a known Int16 value, reads it back, and asserts status=0 + value round-trip. No DL205 quirk assertions yet — those land one-per-PR as the user validates each behavior in ModbusPal (word order for 32-bit, register-zero access, coil addressing base, max registers per FC03, response framing under load, exception code on protected-bit coil write).
ModbusSimulatorFixture is a collection fixture so the 2s TCP probe runs once per run, not per test; SkipReason gets a clear operator-facing message ('start ModbusPal or override MODBUS_SIM_ENDPOINT'). Tests call Assert.Skip(sim.SkipReason) rather than silently returning — matches the test-plan convention and reads cleanly in CI logs. DL205Profile.BuildOptions deliberately disables the background probe loop since integration tests drive reads explicitly and the probe would race with assertions. Tag naming uses the DL205_ prefix so filter 'DisplayName~DL205' surfaces device-specific failures at a glance.
Project references: xunit.v3 + Shouldly + Microsoft.NET.Test.Sdk + xunit.runner.visualstudio (matches the existing Driver.Modbus.Tests unit project), project ref to src/Driver.Modbus. Registered in ZB.MOM.WW.OtOpcUa.slnx under tests/. ModbusPal/README.md documents the dev loop (install ModbusPal jar, load profile, start simulator, dotnet test), explains MODBUS_SIM_ENDPOINT override for real-PLC benchwork, and flags DL205.xmpp as the first profile to add in a follow-up PR.
dotnet test run against the scaffold (no simulator running) skips cleanly: 0 failed, 0 passed, 1 skipped, with the SkipReason surfaced. dotnet build clean (0 warnings, 0 errors). Updated docs/v2/modbus-test-plan.md to mark the scaffold PR done and renumbered future PRs from 'PR 27+' to 'PR 31+' to stay in sync with the actual PR chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:02:39 -04:00
901d2b8019 Merge pull request 'Phase 3 PR 29 — Account/session page with roles + capabilities' (#28) from phase-3-pr29-account-page into v2 2026-04-18 14:46:45 -04:00
Joseph Doherty
d5fa1f450e Phase 3 PR 29 — Account/session page expanding the minimal sidebar role display into a dedicated /account route. Shows the authenticated operator's identity (username from ClaimTypes.NameIdentifier, display name from ClaimTypes.Name), their Admin roles as badges (from ClaimTypes.Role), the raw LDAP groups that mapped to those roles (from the 'ldap_group' claim added by Login.razor at sign-in), and a capability table listing each Admin capability with its required role and a Yes/No badge showing whether this session has it. Capability list mirrors the Program.cs authorization policies + each page's [Authorize] attribute so operators can self-service check whether their session has access without trial-and-error navigation — capabilities covered: view clusters + fleet status (all roles), edit configuration drafts (ConfigEditor or FleetAdmin per CanEdit policy), publish generations (FleetAdmin per CanPublish policy), manage certificate trust (FleetAdmin per PR 28 Certificates page attribute), manage external-ID reservations (ConfigEditor or FleetAdmin per Reservations page attribute).
Sidebar's 'Signed in as' line now wraps the display name in a link to /account so the existing sidebar-compact view becomes the entry point for the fuller page — keeps the sign-out button where it was for muscle memory, just adds the detail page one click away. Page is gated with [Authorize] (any authenticated admin) rather than a specific role — the capability table deliberately works for every signed-in user so they can see what they DON'T have access to, which helps them file the right ticket with their LDAP admin instead of getting a plain Access Denied when navigating blindly.
Capability → required-role table is defined as a private readonly record list in the page rather than pulled from a service because it's a UI-presentation concern, not runtime policy state — the runtime policy IS Program.cs's AddAuthorizationBuilder + each page's [Authorize] attribute, and this table just mirrors it for operator readability. Comment on the list reminds future-me to extend it when a new policy or [Authorize] page lands. No behavior change if roles are empty, but the page surfaces a hint ('Sign-in would have been blocked, so if you're seeing this, the session claim is likely stale') that nudges the operator toward signing out + back in.
No new tests added — the page is pure display over claims; its only logic is the 'has-capability' Any-overlap check which is exactly what ASP.NET's [Authorize(Roles=...)] does in-framework, and duplicating that in a unit test would test the framework rather than our code. Admin.Tests Unit stays 23 pass / 0 fail. Admin build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:43:35 -04:00
6fdaee3a71 Merge pull request 'Phase 3 PR 28 — Admin UI cert-trust management page' (#27) from phase-3-pr28-cert-trust into v2 2026-04-18 14:42:52 -04:00
Joseph Doherty
ed88835d34 Phase 3 PR 28 — Admin UI cert-trust management page. New /certificates route (FleetAdmin-only) surfaces the OPC UA server's PKI store rejected + trusted certs and gives operators Trust / Delete / Revoke actions so rejected client certs can be promoted without touching disk. CertTrustService reads $PkiStoreRoot/{rejected,trusted}/certs/*.der files directly via X509CertificateLoader — no Opc.Ua dependency in the Admin project, which keeps the Admin host runnable on a machine that doesn't have the full Server install locally (only needs the shared PKI directory reachable; typical deployment has Admin + Server side-by-side on the same box and PkiStoreRoot defaults match so a plain-vanilla install needs no override). CertTrustOptions bound from the Admin's 'CertTrust:PkiStoreRoot' section, default %ProgramData%\OtOpcUa\pki (matches OpcUaServerOptions.PkiStoreRoot default). Trust action moves the .der from rejected/certs/ to trusted/certs/ via File.Move(overwrite:true) — idempotent, tolerates a concurrent operator doing the same move. Delete wipes the file. Revoke removes from trusted/certs/ (Opc.Ua re-reads the Directory store on each new client handshake, so no explicit reload signal is needed; operators retry the rejected connection after trusting). Thumbprint matching is case-insensitive because X509Certificate2.Thumbprint is upper-case hex but operators copy-paste from logs that sometimes lowercase it. Malformed files in the store are logged + skipped — a single bad .der can't take the whole management page offline. Missing store directories produce empty lists rather than exceptions so a pristine install (Server never run yet, no rejected/trusted dirs yet) doesn't crash the page.
Razor page layout: two tables (Rejected / Trusted) with Subject / Issuer / Thumbprint / Valid-window / Actions columns, status banner after each action with success or warning kind ('file missing' = another admin handled it), FleetAdmin-only via [Authorize(Roles=AdminRoles.FleetAdmin)]. Each action invokes LogActionAsync which Serilog-logs the authenticated admin user + thumbprint + action for an audit trail — DB-level ConfigAuditLog persistence is deferred because its schema is cluster-scoped and cert actions are cluster-agnostic; Serilog + CertTrustService's filesystem-op info logs give the forensic trail in the meantime. Sidebar link added to MainLayout between Reservations and the future Account page.
Tests — CertTrustServiceTests (9 new unit cases): ListRejected parses Subject + Thumbprint + store kind from a self-signed test cert written into rejected/certs/; rejected and trusted stores are kept separate; TrustRejected moves the file and the Rejected list is empty afterwards; TrustRejected with a thumbprint not in rejected returns false without touching trusted; DeleteRejected removes the file; UntrustCert removes from trusted only; thumbprint match is case-insensitive (operator UX); missing store directories produce empty lists instead of throwing DirectoryNotFoundException (pristine-install tolerance); a junk .der in the store is logged + skipped and the valid certs still surface (one bad file doesn't break the page). Full Admin.Tests Unit suite: 23 pass / 0 fail (14 prior + 9 new). Full Admin build clean — 0 errors, 0 warnings.
lmx-followups.md #3 marked DONE with a cross-reference to this PR and a note that flipping AutoAcceptUntrustedClientCertificates to false as the production default is a deployment-config follow-up, not a code gap — the Admin UI is now ready to be the trust gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:37:55 -04:00
5389d4d22d Phase 3 PR 27 � Fleet status dashboard page (#26) 2026-04-18 14:07:16 -04:00
Joseph Doherty
b5f8661e98 Phase 3 PR 27 — Fleet status dashboard page. New /fleet route shows per-node apply state (ClusterNodeGenerationState joined with ClusterNode for the ClusterId) in a sortable table with summary cards for Total / Applied / Stale / Failed node counts. Stale detection: LastSeenAt older than 30s triggers a table-warning row class + yellow count card. Failed rows get table-danger + red card. Badge classes per LastAppliedStatus: Applied=bg-success, Failed=bg-danger, Applying=bg-info, unknown=bg-secondary. Timestamps rendered as relative-age strings ('42s ago', '15m ago', '3h ago', then absolute date for >24h). Error column is truncated to 320px with the full message in a tooltip so the table stays readable on wide fleets. Initial data load on OnInitializedAsync; auto-refresh every 5s via a Timer that calls InvokeAsync(RefreshAsync) — matches the FleetStatusPoller's 5s cadence so the dashboard sees the most recent state without polling ahead of the broadcaster. A Refresh button also kicks a manual reload; _refreshing gate prevents double-runs when the timer fires during an in-flight query. IServiceScopeFactory (matches FleetStatusPoller's pattern) creates a fresh DI scope per refresh so the per-page DbContext can't race the timer with the render thread; no new DI registrations needed. Live SignalR hub push is deliberately deferred to a follow-up PR — the existing FleetStatusHub + NodeStateChangedMessage already works for external JavaScript clients; wiring an in-process Blazor Server consumer adds HubConnectionBuilder plumbing that's worth its own focused change. Sidebar link added to MainLayout between Overview and Clusters. Full Admin.Tests Unit suite 14 pass / 0 fail — unchanged, no tests regressed. Full Admin build clean (0 errors, 0 warnings). Closes the 'no per-driver dashboard' gap from lmx-followups item #7 at the fleet level; per-host (platform/engine/Modbus PLC) granularity still needs a dedicated page that consumes IHostConnectivityProbe.GetHostStatuses from the Server process — that's the live-SignalR follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:12:31 -04:00
4058b88784 Merge pull request 'Phase 3 PR 26 — server-layer write authorization by role' (#25) from phase-3-pr26-server-write-authz into v2 2026-04-18 13:04:35 -04:00
Joseph Doherty
6b04a85f86 Phase 3 PR 26 — server-layer write authorization gating by role. Per the user's ACL-at-server-layer directive (saved as feedback_acl_at_server_layer.md in memory), write authorization is enforced in DriverNodeManager.OnWriteValue and never delegated to the driver or to driver-specific auth (the v1 Galaxy-provided security path is explicitly not part of v2 — drivers report SecurityClassification as discovery metadata only). New WriteAuthzPolicy static class in Server/Security/ maps SecurityClassification → required role per the table documented in docs/Configuration.md: FreeAccess = no role required (anonymous sessions can write), Operate + SecuredWrite = WriteOperate, Tune = WriteTune, VerifiedWrite + Configure = WriteConfigure, ViewOnly = deny regardless of roles. Role matching is case-insensitive and role requirements do NOT cascade — a session with WriteConfigure can write Configure attributes but needs WriteOperate separately to write Operate attributes; this is deliberate so escalation is an explicit LDAP group assignment, not a hierarchy the policy silently grants. DriverNodeManager gains a _securityByFullRef Dictionary populated during Variable() registration (parallel to the existing _variablesByFullRef) so OnWriteValue can look up the classification in O(1) on the hot path. OnWriteValue casts the session's context.UserIdentity to the new IRoleBearer interface (implemented by OtOpcUaServer.RoleBasedIdentity from PR 19) — empty Roles collection when the session is anonymous; the same WriteAuthzPolicy.IsAllowed check then either short-circuits true (FreeAccess), false (ViewOnly), or walks the roles list looking for the required one. On deny, OnWriteValue logs 'Write denied for {FullRef}: classification=X userRoles=[...]' at Information level (readable trail for operator complaints) and returns BadUserAccessDenied without touching IWritable.WriteAsync — drivers never see a request we'd have refused. IRoleBearer kept as a minimal server-side interface rather than reusing some abstraction from Core.Abstractions because the concept is OPC-UA-session-scoped and doesn't generalize (the driver side has no notion of a user session). Tests — WriteAuthzPolicyTests (17 new cases): FreeAccess allows write with empty role set + arbitrary roles; ViewOnly denies write even with every role; Operate requires WriteOperate; role match is case-insensitive; Operate denies empty role set + wrong role; SecuredWrite shares Operate's requirement; Tune requires WriteTune; Tune denies WriteOperate-only (asserts roles don't cascade — this is the test that catches a future regression where someone 'helpfully' adds a role-escalation table); Configure requires WriteConfigure; VerifiedWrite shares Configure's requirement; multi-role session allowed when any role matches; unrelated roles denied; RequiredRole theory covering all 5 classified-and-mapped rows + null for FreeAccess/ViewOnly special cases. lmx-followups.md follow-up #2 marked DONE with a back-reference to this PR and the memory note. Full Server.Tests Unit suite: 38 pass / 0 fail (17 new WriteAuthz + 14 SecurityConfiguration from PR 19 + 2 NodeBootstrap + 5 others). Server.Tests Integration (Category=Integration) 2 pass — existing PR 17 anonymous-endpoint smoke tests stay green since the read path doesn't hit OnWriteValue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:01:01 -04:00
cd8691280a Merge pull request 'Phase 3 PR 25 — Modbus test plan + DL205 quirk catalog' (#24) from phase-3-pr25-modbus-test-plan into v2 2026-04-18 12:49:19 -04:00
Joseph Doherty
77d09bf64e Phase 3 PR 25 — modbus-test-plan.md: integration-test playbook with per-device quirk catalog. ModbusPal is the chosen simulator; AutomationDirect DL205 is the first target device class with 6 pending quirks to document and cover with named tests (word order for 32-bit values, register-zero access policy, coil addressing base, maximum registers per FC03, response framing under sustained load, exception code on protected-bit coil write). Each quirk placeholder has a proposed test name so the user's validation work translates directly into integration tests. Test conventions section codifies the named-per-quirk pattern, skip-when-unreachable guard, real ModbusTcpTransport usage, and inter-test isolation. Sets up the harness-and-catalog structure future device families (Allen-Bradley Micrologix, Siemens S7-1200 Modbus gateway, Schneider M340, whatever the user hits) will slot into — same per-device catalog shape, cross-device patterns section for recurring quirks that can get promoted into driver defaults. Next concrete PRs proposed: PR 26 for the integration test project scaffold + DL205 profile + fixture with skip-guard + one smoke test, PR 27+ for the individual confirmed quirks one-per-PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:45:21 -04:00
163c821e74 Merge pull request 'Phase 3 PR 24 — Modbus PLC data type extensions' (#23) from phase-3-pr24-modbus-types into v2 2026-04-18 12:32:55 -04:00
78 changed files with 8269 additions and 102 deletions

1
.gitignore vendored
View File

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

View File

@@ -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"/>

View File

@@ -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
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,100 +7,189 @@ 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.
## 2. Write-gating by role
**Tests**:
**Status**: `RoleBasedIdentity.Roles` populated on the session (PR 19) but
`DriverNodeManager.OnWriteValue` doesn't consult it.
- `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.
CLAUDE.md defines the role set: `ReadOnly` / `WriteOperate` / `WriteTune` /
`WriteConfigure` / `AlarmAck`. Each `DriverAttributeInfo.SecurityClassification`
maps to a required role for writes.
**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.
**To do**:
- Add a `RoleRequirements` table: `SecurityClassification` → required role.
- `OnWriteValue` reads `context.UserIdentity` → cast to `RoleBasedIdentity`
→ check role membership before calling `IWritable.WriteAsync`. Return
`BadUserAccessDenied` on miss.
- Unit test against a fake `ISystemContext` with varying role sets.
## 2. Write-gating by role — **DONE (PR 26)**
## 3. Admin UI client-cert trust management
Landed in PR 26. `WriteAuthzPolicy` in `Server/Security/` maps
`SecurityClassification` → required role (`FreeAccess` → no role required,
`Operate`/`SecuredWrite``WriteOperate`, `Tune``WriteTune`,
`Configure`/`VerifiedWrite``WriteConfigure`, `ViewOnly` → deny regardless).
`DriverNodeManager` caches the classification per variable during discovery and
checks the session's roles (via `IRoleBearer`) in `OnWriteValue` before calling
`IWritable.WriteAsync`. Roles do not cascade — a session with `WriteOperate`
can't write a `Tune` attribute unless it also carries `WriteTune`.
**Status**: Server side auto-accepts untrusted client certs when the
`AutoAcceptUntrustedClientCertificates` option is true (dev default).
Production deployments want operator-controlled trust via the Admin UI.
See `feedback_acl_at_server_layer.md` in memory for the architectural directive
that authz stays at the server layer and never delegates to driver-specific auth.
**To do**:
- Surface the server's rejected-certificate store in the Admin UI.
- Page to move certs between `rejected` / `trusted`.
- Flip `AutoAcceptUntrustedClientCertificates` to false once Admin UI is the
trust gate.
## 3. Admin UI client-cert trust management — **DONE (PR 28)**
## 4. Live-LDAP integration test
PR 28 shipped `/certificates` in the Admin UI. `CertTrustService` reads the OPC
UA server's PKI store root (`OpcUaServerOptions.PkiStoreRoot` — default
`%ProgramData%\OtOpcUa\pki`) and lists rejected + trusted certs by parsing the
`.der` files directly, so it has no `Opc.Ua` dependency and runs on any
Admin host that can reach the shared PKI directory.
**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`.
Operator actions: Trust (moves `rejected/certs/*.der``trusted/certs/*.der`),
Delete rejected, Revoke trust. The OPC UA stack re-reads the trusted store on
each new client handshake, so no explicit reload signal is needed —
operators retry the rejected client's connection after trusting.
**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.
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.
## 5. Full Galaxy live-service smoke test against the merged v2 stack
## 4. Live-LDAP integration test — **DONE (PR 31)**
**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.
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**:
- 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.
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.
## 6. Second driver instance on the same server
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**: `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.
## 5. Full Galaxy live-service smoke test against the merged v2 stack — **IN PROGRESS (PRs 36 + 37)**
**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.
PR 36 shipped the prerequisites helper (`AvevaPrerequisites`) that probes
every dependency a live smoke test needs and produces actionable skip
messages.
## 7. Host-status per-AppEngine granularity → Admin UI dashboard
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**: 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.
- 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**:
- SignalR hub push of `HostStatusChangedEventArgs` to the Admin UI.
- Dashboard page showing each tracked host, current state, last transition
time, failure count.
Shared secret + pipe name resolve from `OTOPCUA_GALAXY_SECRET` /
`OTOPCUA_GALAXY_PIPE` env vars, falling back to reading the service's
registry-stored Environment values (requires elevated test host).
**PR 40** added the write + subscribe facts targeting
`DelmiaReceiver_001.TestAttribute` (the writable Boolean UDA the dev Galaxy
ships under TestMachine_001) — write-then-read with a 5s scan-window poll +
restore-on-finally, and subscribe-then-write asserting both an initial-value
OnDataChange and a post-write OnDataChange. PR 39 added the elevated-shell
short-circuit so a developer running from an admin window gets an actionable
skip instead of `UnauthorizedAccessException`.
**Run the live tests** (from a NORMAL non-admin PowerShell):
```powershell
$env:OTOPCUA_GALAXY_SECRET = Get-Content C:\Users\dohertj2\Desktop\lmxopcua\.local\galaxy-host-secret.txt
cd C:\Users\dohertj2\Desktop\lmxopcua
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests --filter "FullyQualifiedName~LiveStackSmokeTests"
```
Expected: 7/7 pass against the running `OtOpcUaGalaxyHost` service.
**Remaining for #5 in production-grade form**:
- Confirm the suite passes from a non-elevated shell (operator action).
- Add similar facts for an alarm-source attribute once `TestMachine_001` (or
a sibling) carries a deployed alarm condition — the current dev Galaxy's
TestAttribute isn't alarm-flagged.
## 6. Second driver instance on the same server — **DONE (PR 32)**
`Server.Tests/MultipleDriverInstancesIntegrationTests.cs` registers two
drivers with distinct `DriverInstanceId`s on one `DriverHost`, spins up the
full OPC UA server, and asserts three behaviors: (1) each driver's namespace
URI (`urn:OtOpcUa:{id}`) resolves to a distinct index in the client's
NamespaceUris, (2) browsing one subtree returns that driver's folder and
does NOT leak the other driver's folder, (3) reads route to the correct
driver — the alpha instance returns 42 while beta returns 99, so a misroute
would surface at the assertion layer.
Deferred: the alarm-event multi-driver parity case (two drivers each raising
a `GalaxyAlarmEvent`, assert each condition lands on its owning instance's
condition node). Alarm tracking already has its own integration test
(`AlarmSubscription*`); the multi-driver alarm case would need a stub
`IAlarmSource` that's worth its own focused PR.
## 7. Host-status per-AppEngine granularity → Admin UI dashboard — **DONE (PRs 33 + 34)**
**PR 33** landed the data layer: `DriverHostStatus` entity + migration with
composite key `(NodeId, DriverInstanceId, HostName)` and two query-supporting
indexes (per-cluster drill-down on `NodeId`, stale-row detection on
`LastSeenUtc`).
**PR 34** wired the publisher + consumer. `HostStatusPublisher` is a
`BackgroundService` in the Server process that walks every registered
`IHostConnectivityProbe`-capable driver every 10s, calls
`GetHostStatuses()`, and upserts rows (`LastSeenUtc` advances each tick;
`State` + `StateChangedUtc` update on transitions). Admin UI `/hosts` page
groups by cluster, shows four summary cards (Hosts / Running / Stale /
Faulted), and flags rows whose `LastSeenUtc` is older than 30s as Stale so
operators see crashed Servers without waiting for a state change.
Deferred as follow-ups:
- Event-driven push (subscribe to `OnHostStatusChanged` per driver for
sub-heartbeat latency). Adds DriverHost lifecycle-event plumbing;
10s polling is fine for operator-scale use.
- Failure-count column — needs the publisher to track a transition history
per host, not just current-state.
- SignalR fan-out to the Admin page (currently the page polls the DB, not
a hub). The DB-polled version is fine at current cadence but a hub push
would eliminate the 10s race where a new row sits in the DB before the
Admin page notices.

121
docs/v2/modbus-test-plan.md Normal file
View File

@@ -0,0 +1,121 @@
# Modbus driver — test plan + device-quirk catalog
The Modbus TCP driver unit tests (PRs 2124) cover the protocol surface against an
in-memory fake transport. They validate the codec, state machine, and function-code
routing against a textbook Modbus server. That's necessary but not sufficient: real PLC
populations disagree with the spec in small, device-specific ways, and a driver that
passes textbook tests can still misbehave against actual equipment.
This doc is the harness-and-quirks playbook. The project it describes lives at
`tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/` — scaffolded in PR 30 with
the simulator fixture, DL205 profile stub, and one write/read smoke test. Each
confirmed DL205 quirk lands in a follow-up PR as a named test in that project.
## Harness
**Chosen simulator: pymodbus 3.13.0** (`pip install 'pymodbus[simulator]==3.13.0'`).
Replaced ModbusPal in PR 43 — see `tests/.../Pymodbus/README.md` for the
trade-off rationale. Headline reasons:
- **Headless** pure-Python CLI; no Java GUI, runs cleanly on a CI runner.
- **Maintained** — current stable 3.13.0; ModbusPal 1.6b is abandoned.
- **All four standard tables** (HR, IR, coils, DI) configurable; ModbusPal
1.6b only exposed HR + coils.
- **Built-in actions** (`increment`, `random`, `timestamp`, `uptime`) +
optional custom-Python actions for declarative dynamic behaviors.
- **Per-register raw uint16 seeding** — encoding the DL205 string-byte-order
/ BCD / CDAB-float quirks stays explicit (the quirk math lives in the
`_quirk` JSON-comment fields next to each register).
- Pip-installable on Windows; sidesteps the privileged-port admin
requirement by defaulting to TCP **5020** instead of 502.
**Setup pattern**:
1. `pip install "pymodbus[simulator]==3.13.0"`.
2. Start the simulator with one of the in-repo profiles:
`tests\.../Pymodbus\serve.ps1 -Profile standard` (or `-Profile dl205`).
3. `dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests`
tests auto-skip when the endpoint is unreachable. Default endpoint is
`localhost:5020`; override via `MODBUS_SIM_ENDPOINT` for a real PLC on its
native port 502.
## Per-device quirk catalog
### AutomationDirect DL205 / DL260
First known target device family. **Full quirk catalog with primary-source citations
and per-quirk integration-test names lives at [`dl205.md`](dl205.md)** — that doc is
the reference; this section is the testing roadmap.
Confirmed quirks (priority order — top items are highest-impact for our driver
and ship first as PR 41+):
| Quirk | Driver impact | Integration-test name |
|---|---|---|
| **String packing**: 2 chars/register, **first char in low byte** (opposite of generic Modbus) | `ModbusDataType.String` decoder must be configurable per-device family — current code assumes high-byte-first | `DL205_String_low_byte_first_within_register` |
| **Word order CDAB** for Int32/UInt32/Float32 | Already configurable via `ModbusByteOrder.WordSwap`; default per device profile | `DL205_Int32_word_order_is_CDAB` |
| **BCD-as-default** numeric storage (only IEEE 754 when ladder uses `R` type) | New decoder mode — register reads as `0x1234` for ladder value `1234`, not as decimal `4660` | `DL205_BCD_register_decodes_as_hex_nibbles` |
| **FC16 capped at 100 registers** (below the spec's 123) | Bulk-write batching must cap per-device-family | `DL205_FC16_101_registers_returns_IllegalDataValue` |
| **FC03/04 capped at 128** (above the spec's 125) | Less impactful — clients that respect the spec's 125 stay safe | `DL205_FC03_129_registers_returns_IllegalDataValue` |
| **V-memory octal-to-decimal addressing** (V2000 octal → 0x0400 decimal) | New address-format helper in profile config so operators can write `V2000` instead of computing `1024` themselves | `DL205_Vmem_V2000_maps_to_PDU_0x0400` |
| **C-relay → coil 3072 / Y-output → coil 2048** offsets | Hard-coded constants in DL205 device profile | `DL205_C0_maps_to_coil_3072`, `DL205_Y0_maps_to_coil_2048` |
| **Register 0 is valid** (rejects-register-0 rumour was DL05/DL06 relative-mode artefact) | None — current default is safe | `DL205_FC03_register_0_returns_V0_contents` |
| **Max 4 simultaneous TCP clients** on H2-ECOM100 | Connect-time: handle TCP-accept failure with a clearer error message | `DL205_5th_TCP_connection_refused` |
| **No TCP keepalive** | Driver-side periodic-probe (already wired via `IHostConnectivityProbe`) | _Covered by existing `ModbusProbeTests`_ |
| **No mid-stream resync on malformed MBAP** | Already covered — single-flight + reconnect-on-error | _Covered by existing `ModbusDriverTests`_ |
| **Write-protect exception code: `02` newer / `04` older** | Translate either to `BadNotWritable` | `DL205_FC06_in_ProgramMode_returns_ServerFailure` |
_Operator-reported / unconfirmed_ — covered defensively in the driver but no
integration tests until reproduced on hardware:
- TxId drop under load (forum rumour; not reproduced).
- Pre-2004 firmware ABCD word order (every shipped DL205/DL260 since 2004 is CDAB).
### Future devices
One section per device class, same shape as DL205. Quirks that apply across
multiple devices (e.g., "all AB PLCs use CDAB") can be noted in the cross-device
patterns section below once we have enough data points.
## Cross-device patterns
Once multiple device catalogs accumulate, quirks that recur across two or more
vendors get promoted into driver defaults or opt-in options:
- _(empty — filled in as catalogs grow)_
## Test conventions
- **One named test per quirk.** `DL205_word_order_is_CDAB_for_Float32` is easier to
diagnose on failure than a generic `Float32_roundtrip`. The `DL205_` prefix makes
filtering by device class trivial (`--filter "DisplayName~DL205"`).
- **Skip with a clear SkipReason.** Follow the pattern from
`GalaxyRepositoryLiveSmokeTests`: check reachability in the fixture, capture
a `SkipReason` string, and have each test call `Assert.Skip(SkipReason)` when
it's set. Don't throw — skipped tests read cleanly in CI logs.
- **Use the real `ModbusTcpTransport`.** Integration tests exercise the wire
protocol end-to-end. The in-memory `FakeTransport` from the unit test suite is
deliberately not used here — its value is speed + determinism, which doesn't
help reproduce device-specific issues.
- **Don't depend on simulator state between tests.** Each test resets the
simulator's register bank or uses a unique address range. Avoid relying on
"previous test left value at register 10" setups that flake when tests run in
parallel or re-order. Either the test mutates the scratch ranges and restores
on finally, or it uses pymodbus's REST API to reset state between facts.
## Next concrete PRs
- **PR 30 — Integration test project + DL205 profile scaffold** — **DONE**.
Shipped `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` with
`ModbusSimulatorFixture` (TCP-probe, skips with a clear `SkipReason` when the
endpoint is unreachable), `DL205/DL205Profile.cs` (tag map stub), and
`DL205/DL205SmokeTests.cs` (write-then-read round-trip).
- **PR 41 — DL205 quirk catalog doc** — **DONE**. `docs/v2/dl205.md`
documents every DL205/DL260 Modbus divergence with primary-source citations.
- **PR 42 — ModbusPal `.xmpp` profiles** — **SUPERSEDED by PR 43**. Replaced
with pymodbus JSON because ModbusPal 1.6b is abandoned, GUI-only, and only
exposes 2 of the 4 standard tables.
- **PR 43 — pymodbus JSON profiles** — **DONE**. `Pymodbus/standard.json` +
`Pymodbus/dl205.json` + `Pymodbus/serve.ps1` runner. Both bind TCP 5020.
- **PR 44+**: one PR per confirmed DL205 quirk, landing the named test + any
driver-side adjustment (string byte order, BCD decoder, V-memory address
helper, FC16 cap-per-device-family) needed to pass it. Each quirk's value
is already pre-encoded in `Pymodbus/dl205.json`.

View File

@@ -5,15 +5,18 @@
<h5 class="mb-4">OtOpcUa Admin</h5>
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link text-light" href="/">Overview</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/fleet">Fleet status</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/hosts">Host status</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/certificates">Certificates</a></li>
</ul>
<div class="mt-5">
<AuthorizeView>
<Authorized>
<div class="small text-light">
Signed in as <strong>@context.User.Identity?.Name</strong>
Signed in as <a class="text-light" href="/account"><strong>@context.User.Identity?.Name</strong></a>
</div>
<div class="small text-muted">
@string.Join(", ", context.User.Claims.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))

View File

@@ -0,0 +1,129 @@
@page "/account"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@using System.Security.Claims
@using ZB.MOM.WW.OtOpcUa.Admin.Services
<h1 class="mb-4">My account</h1>
<AuthorizeView>
<Authorized>
@{
var username = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "—";
var displayName = context.User.Identity?.Name ?? "—";
var roles = context.User.Claims
.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToList();
var ldapGroups = context.User.Claims
.Where(c => c.Type == "ldap_group").Select(c => c.Value).ToList();
}
<div class="row g-4">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Identity</h5>
<dl class="row mb-0">
<dt class="col-sm-4">Username</dt><dd class="col-sm-8"><code>@username</code></dd>
<dt class="col-sm-4">Display name</dt><dd class="col-sm-8">@displayName</dd>
</dl>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Admin roles</h5>
@if (roles.Count == 0)
{
<p class="text-muted mb-0">No Admin roles mapped — sign-in would have been blocked, so if you're seeing this, the session claim is likely stale.</p>
}
else
{
<div class="mb-2">
@foreach (var r in roles)
{
<span class="badge bg-primary me-1">@r</span>
}
</div>
<small class="text-muted">LDAP groups: @(ldapGroups.Count == 0 ? "(none surfaced)" : string.Join(", ", ldapGroups))</small>
}
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">Capabilities</h5>
<p class="text-muted small">
Each Admin role grants a fixed capability set per <code>admin-ui.md</code> §Admin Roles.
Pages below reflect what this session can access; the route's <code>[Authorize]</code> guard
is the ground truth — this table mirrors it for readability.
</p>
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Capability</th>
<th>Required role(s)</th>
<th class="text-end">You have it?</th>
</tr>
</thead>
<tbody>
@foreach (var cap in Capabilities)
{
var has = cap.RequiredRoles.Any(r => roles.Contains(r, StringComparer.OrdinalIgnoreCase));
<tr>
<td>@cap.Name<br /><small class="text-muted">@cap.Description</small></td>
<td>@string.Join(" or ", cap.RequiredRoles)</td>
<td class="text-end">
@if (has)
{
<span class="badge bg-success">Yes</span>
}
else
{
<span class="badge bg-secondary">No</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="mt-4">
<form method="post" action="/auth/logout">
<button class="btn btn-outline-danger" type="submit">Sign out</button>
</form>
</div>
</Authorized>
</AuthorizeView>
@code {
private sealed record Capability(string Name, string Description, string[] RequiredRoles);
// Kept in sync with Program.cs authorization policies + each page's [Authorize] attribute.
// When a new page or policy is added, extend this list so operators can self-service check
// whether their session has access without trial-and-error navigation.
private static readonly IReadOnlyList<Capability> Capabilities =
[
new("View clusters + fleet status",
"Read-only access to the cluster list, fleet dashboard, and generation history.",
[AdminRoles.ConfigViewer, AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
new("Edit configuration drafts",
"Create and edit draft generations, manage namespace bindings and node ACLs. CanEdit policy.",
[AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
new("Publish generations",
"Promote a draft to Published — triggers node roll-out. CanPublish policy.",
[AdminRoles.FleetAdmin]),
new("Manage certificate trust",
"Trust rejected client certs + revoke trust. FleetAdmin-only because the trust decision gates OPC UA client access.",
[AdminRoles.FleetAdmin]),
new("Manage external-ID reservations",
"Reserve / release external IDs that map into Galaxy contained names.",
[AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
];
}

View File

@@ -0,0 +1,154 @@
@page "/certificates"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = AdminRoles.FleetAdmin)]
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@inject CertTrustService Certs
@inject AuthenticationStateProvider AuthState
@inject ILogger<Certificates> Log
<h1 class="mb-4">Certificate trust</h1>
<div class="alert alert-info small mb-4">
PKI store root <code>@Certs.PkiStoreRoot</code>. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake, so operators should retry the rejected client's connection after trusting.
</div>
@if (_status is not null)
{
<div class="alert alert-@_statusKind alert-dismissible">
@_status
<button type="button" class="btn-close" @onclick="ClearStatus"></button>
</div>
}
<h2 class="h4">Rejected (@_rejected.Count)</h2>
@if (_rejected.Count == 0)
{
<p class="text-muted">No rejected certificates. Clients that fail to handshake with an untrusted cert land here.</p>
}
else
{
<table class="table table-sm align-middle">
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
<tbody>
@foreach (var c in _rejected)
{
<tr>
<td>@c.Subject</td>
<td>@c.Issuer</td>
<td><code class="small">@c.Thumbprint</code></td>
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
<td class="text-end">
<button class="btn btn-sm btn-success me-1" @onclick="() => TrustAsync(c)">Trust</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteRejectedAsync(c)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
<h2 class="h4 mt-5">Trusted (@_trusted.Count)</h2>
@if (_trusted.Count == 0)
{
<p class="text-muted">No client certs have been explicitly trusted. The server's own application cert lives in <code>own/</code> and is not listed here.</p>
}
else
{
<table class="table table-sm align-middle">
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
<tbody>
@foreach (var c in _trusted)
{
<tr>
<td>@c.Subject</td>
<td>@c.Issuer</td>
<td><code class="small">@c.Thumbprint</code></td>
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" @onclick="() => UntrustAsync(c)">Revoke</button>
</td>
</tr>
}
</tbody>
</table>
}
@code {
private IReadOnlyList<CertInfo> _rejected = [];
private IReadOnlyList<CertInfo> _trusted = [];
private string? _status;
private string _statusKind = "success";
protected override void OnInitialized() => Reload();
private void Reload()
{
_rejected = Certs.ListRejected();
_trusted = Certs.ListTrusted();
}
private async Task TrustAsync(CertInfo c)
{
if (Certs.TrustRejected(c.Thumbprint))
{
await LogActionAsync("cert.trust", c);
Set($"Trusted cert {c.Subject} ({Short(c.Thumbprint)}).", "success");
}
else
{
Set($"Could not trust {Short(c.Thumbprint)} — file missing; another admin may have already handled it.", "warning");
}
Reload();
}
private async Task DeleteRejectedAsync(CertInfo c)
{
if (Certs.DeleteRejected(c.Thumbprint))
{
await LogActionAsync("cert.delete.rejected", c);
Set($"Deleted rejected cert {c.Subject} ({Short(c.Thumbprint)}).", "success");
}
else
{
Set($"Could not delete {Short(c.Thumbprint)} — file missing.", "warning");
}
Reload();
}
private async Task UntrustAsync(CertInfo c)
{
if (Certs.UntrustCert(c.Thumbprint))
{
await LogActionAsync("cert.untrust", c);
Set($"Revoked trust for {c.Subject} ({Short(c.Thumbprint)}).", "success");
}
else
{
Set($"Could not revoke {Short(c.Thumbprint)} — file missing.", "warning");
}
Reload();
}
private async Task LogActionAsync(string action, CertInfo c)
{
// Cert trust changes are operator-initiated and security-sensitive — Serilog captures the
// user + thumbprint trail. CertTrustService also logs at Information on each filesystem
// move/delete; this line ties the action to the authenticated admin user so the two logs
// correlate. DB-level ConfigAuditLog persistence is deferred — its schema is
// cluster-scoped and cert actions are cluster-agnostic.
var state = await AuthState.GetAuthenticationStateAsync();
var user = state.User.Identity?.Name ?? "(anonymous)";
Log.LogInformation("Admin cert action: user={User} action={Action} thumbprint={Thumbprint} subject={Subject}",
user, action, c.Thumbprint, c.Subject);
}
private void Set(string message, string kind)
{
_status = message;
_statusKind = kind;
}
private void ClearStatus() => _status = null;
private static string Short(string thumbprint) =>
thumbprint.Length > 12 ? thumbprint[..12] + "…" : thumbprint;
}

View File

@@ -0,0 +1,172 @@
@page "/fleet"
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IServiceScopeFactory ScopeFactory
@implements IDisposable
<h1 class="mb-4">Fleet status</h1>
<div class="d-flex align-items-center mb-3 gap-2">
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
Refresh
</button>
<span class="text-muted small">
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
</span>
</div>
@if (_rows is null)
{
<p>Loading…</p>
}
else if (_rows.Count == 0)
{
<div class="alert alert-info">
No node state recorded yet. Nodes publish their state to the central DB on each poll; if
this list is empty, either no nodes have been registered or the poller hasn't run yet.
</div>
}
else
{
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card"><div class="card-body">
<h6 class="text-muted mb-1">Nodes</h6>
<div class="fs-3">@_rows.Count</div>
</div></div>
</div>
<div class="col-md-3">
<div class="card border-success"><div class="card-body">
<h6 class="text-muted mb-1">Applied</h6>
<div class="fs-3 text-success">@_rows.Count(r => r.Status == "Applied")</div>
</div></div>
</div>
<div class="col-md-3">
<div class="card border-warning"><div class="card-body">
<h6 class="text-muted mb-1">Stale</h6>
<div class="fs-3 text-warning">@_rows.Count(r => IsStale(r))</div>
</div></div>
</div>
<div class="col-md-3">
<div class="card border-danger"><div class="card-body">
<h6 class="text-muted mb-1">Failed</h6>
<div class="fs-3 text-danger">@_rows.Count(r => r.Status == "Failed")</div>
</div></div>
</div>
</div>
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Node</th>
<th>Cluster</th>
<th>Generation</th>
<th>Status</th>
<th>Last applied</th>
<th>Last seen</th>
<th>Error</th>
</tr>
</thead>
<tbody>
@foreach (var r in _rows)
{
<tr class="@RowClass(r)">
<td><code>@r.NodeId</code></td>
<td>@r.ClusterId</td>
<td>@(r.GenerationId?.ToString() ?? "—")</td>
<td>
<span class="badge @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
</td>
<td>@FormatAge(r.AppliedAt)</td>
<td class="@(IsStale(r) ? "text-warning" : "")">@FormatAge(r.SeenAt)</td>
<td class="text-truncate" style="max-width: 320px;" title="@r.Error">@r.Error</td>
</tr>
}
</tbody>
</table>
}
@code {
// Refresh cadence. 5s matches FleetStatusPoller's poll interval — the dashboard always sees
// the most recent published state without polling ahead of the broadcaster.
private const int RefreshIntervalSeconds = 5;
private List<FleetNodeRow>? _rows;
private bool _refreshing;
private DateTime? _lastRefreshUtc;
private Timer? _timer;
protected override async Task OnInitializedAsync()
{
await RefreshAsync();
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
state: null,
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
}
private async Task RefreshAsync()
{
if (_refreshing) return;
_refreshing = true;
try
{
using var scope = ScopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new FleetNodeRow(
s.NodeId, n.ClusterId, s.CurrentGenerationId,
s.LastAppliedStatus != null ? s.LastAppliedStatus.ToString() : null,
s.LastAppliedError, s.LastAppliedAt, s.LastSeenAt))
.OrderBy(r => r.ClusterId)
.ThenBy(r => r.NodeId)
.ToListAsync();
_rows = rows;
_lastRefreshUtc = DateTime.UtcNow;
}
finally
{
_refreshing = false;
StateHasChanged();
}
}
private static bool IsStale(FleetNodeRow r)
{
if (r.SeenAt is null) return true;
return (DateTime.UtcNow - r.SeenAt.Value) > TimeSpan.FromSeconds(30);
}
private static string RowClass(FleetNodeRow r) => r.Status switch
{
"Failed" => "table-danger",
_ when IsStale(r) => "table-warning",
_ => "",
};
private static string StatusBadge(string? status) => status switch
{
"Applied" => "bg-success",
"Failed" => "bg-danger",
"Applying" => "bg-info",
_ => "bg-secondary",
};
private static string FormatAge(DateTime? t)
{
if (t is null) return "—";
var age = DateTime.UtcNow - t.Value;
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
return t.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
}
public void Dispose() => _timer?.Dispose();
internal sealed record FleetNodeRow(
string NodeId, string ClusterId, long? GenerationId,
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
}

View 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();
}

View File

@@ -47,6 +47,13 @@ builder.Services.AddScoped<NodeAclService>();
builder.Services.AddScoped<ReservationService>();
builder.Services.AddScoped<DraftValidationService>();
builder.Services.AddScoped<AuditLogService>();
builder.Services.AddScoped<HostStatusService>();
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
// filesystem operations.
builder.Services.Configure<CertTrustOptions>(builder.Configuration.GetSection(CertTrustOptions.SectionName));
builder.Services.AddSingleton<CertTrustService>();
// LDAP auth — parity with ScadaLink's LdapAuthService (decision #102).
builder.Services.Configure<LdapOptions>(

View File

@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Points the Admin UI at the OPC UA Server's PKI store root so
/// <see cref="CertTrustService"/> can list and move certs between the
/// <c>rejected/</c> and <c>trusted/</c> directories the server maintains. Must match the
/// <c>OpcUaServer:PkiStoreRoot</c> the Server process is configured with.
/// </summary>
public sealed class CertTrustOptions
{
public const string SectionName = "CertTrust";
/// <summary>
/// Absolute path to the PKI root. Defaults to
/// <c>%ProgramData%\OtOpcUa\pki</c> — matches <c>OpcUaServerOptions.PkiStoreRoot</c>'s
/// default so a standard side-by-side install needs no override.
/// </summary>
public string PkiStoreRoot { get; init; } =
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"OtOpcUa", "pki");
}

View File

@@ -0,0 +1,135 @@
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Metadata for a certificate file found in one of the OPC UA server's PKI stores. The
/// <see cref="FilePath"/> is the absolute path of the DER/CRT file the stack created when it
/// rejected the cert (for <see cref="CertStoreKind.Rejected"/>) or when an operator trusted
/// it (for <see cref="CertStoreKind.Trusted"/>).
/// </summary>
public sealed record CertInfo(
string Thumbprint,
string Subject,
string Issuer,
DateTime NotBefore,
DateTime NotAfter,
string FilePath,
CertStoreKind Store);
public enum CertStoreKind
{
Rejected,
Trusted,
}
/// <summary>
/// Filesystem-backed view over the OPC UA server's PKI store. The Opc.Ua stack uses a
/// Directory-typed store — each cert is a <c>.der</c> file under <c>{root}/{store}/certs/</c>
/// with a filename derived from subject + thumbprint. This service exposes operators for the
/// Admin UI: list rejected, list trusted, trust a rejected cert (move to trusted), remove a
/// rejected cert (delete), untrust a previously trusted cert (delete from trusted).
/// </summary>
/// <remarks>
/// The Admin process is separate from the Server process; this service deliberately has no
/// Opc.Ua dependency — it works on the on-disk layout directly so it can run on the Admin
/// host even when the Server isn't installed locally, as long as the PKI root is reachable
/// (typical deployment has Admin + Server side-by-side on the same machine).
///
/// Trust/untrust requires the Server to re-read its trust list. The Opc.Ua stack re-reads
/// the Directory store on each new incoming connection, so there's no explicit signal
/// needed — the next client handshake picks up the change. Operators should retry the
/// rejected client's connection after trusting.
/// </remarks>
public sealed class CertTrustService
{
private readonly CertTrustOptions _options;
private readonly ILogger<CertTrustService> _logger;
public CertTrustService(IOptions<CertTrustOptions> options, ILogger<CertTrustService> logger)
{
_options = options.Value;
_logger = logger;
}
public string PkiStoreRoot => _options.PkiStoreRoot;
public IReadOnlyList<CertInfo> ListRejected() => ListStore(CertStoreKind.Rejected);
public IReadOnlyList<CertInfo> ListTrusted() => ListStore(CertStoreKind.Trusted);
/// <summary>
/// Move the cert with <paramref name="thumbprint"/> from the rejected store to the
/// trusted store. No-op returns false if the rejected file doesn't exist (already moved
/// by another operator, or thumbprint mismatch). Overwrites an existing trusted copy
/// silently — idempotent.
/// </summary>
public bool TrustRejected(string thumbprint)
{
var cert = FindInStore(CertStoreKind.Rejected, thumbprint);
if (cert is null) return false;
var trustedDir = CertsDir(CertStoreKind.Trusted);
Directory.CreateDirectory(trustedDir);
var destPath = Path.Combine(trustedDir, Path.GetFileName(cert.FilePath));
File.Move(cert.FilePath, destPath, overwrite: true);
_logger.LogInformation("Trusted cert {Thumbprint} (subject={Subject}) — moved {From} → {To}",
cert.Thumbprint, cert.Subject, cert.FilePath, destPath);
return true;
}
public bool DeleteRejected(string thumbprint) => DeleteFromStore(CertStoreKind.Rejected, thumbprint);
public bool UntrustCert(string thumbprint) => DeleteFromStore(CertStoreKind.Trusted, thumbprint);
private bool DeleteFromStore(CertStoreKind store, string thumbprint)
{
var cert = FindInStore(store, thumbprint);
if (cert is null) return false;
File.Delete(cert.FilePath);
_logger.LogInformation("Deleted cert {Thumbprint} (subject={Subject}) from {Store} store",
cert.Thumbprint, cert.Subject, store);
return true;
}
private CertInfo? FindInStore(CertStoreKind store, string thumbprint) =>
ListStore(store).FirstOrDefault(c =>
string.Equals(c.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase));
private IReadOnlyList<CertInfo> ListStore(CertStoreKind store)
{
var dir = CertsDir(store);
if (!Directory.Exists(dir)) return [];
var results = new List<CertInfo>();
foreach (var path in Directory.EnumerateFiles(dir))
{
// Skip CRL sidecars + private-key files — trust operations only concern public certs.
var ext = Path.GetExtension(path);
if (!ext.Equals(".der", StringComparison.OrdinalIgnoreCase) &&
!ext.Equals(".crt", StringComparison.OrdinalIgnoreCase) &&
!ext.Equals(".cer", StringComparison.OrdinalIgnoreCase))
{
continue;
}
try
{
var cert = X509CertificateLoader.LoadCertificateFromFile(path);
results.Add(new CertInfo(
cert.Thumbprint, cert.Subject, cert.Issuer,
cert.NotBefore.ToUniversalTime(), cert.NotAfter.ToUniversalTime(),
path, store));
}
catch (Exception ex)
{
// A malformed file in the store shouldn't take down the page. Surface it in logs
// but skip — operators see the other certs and can clean the bad file manually.
_logger.LogWarning(ex, "Failed to parse cert at {Path} — skipping", path);
}
}
return results;
}
private string CertsDir(CertStoreKind store) =>
Path.Combine(_options.PkiStoreRoot, store == CertStoreKind.Rejected ? "rejected" : "trusted", "certs");
}

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

View File

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

View 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,
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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")

View File

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

View File

@@ -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 &amp; 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);

View File

@@ -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>.

View File

@@ -0,0 +1,74 @@
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;
}
}

View File

@@ -404,8 +404,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 +435,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 +483,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 +521,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 +577,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 +605,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 &gt; 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");

View File

@@ -55,6 +55,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 +69,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 +89,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 +114,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,
}

View File

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

View 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,
};
}

View File

@@ -3,7 +3,13 @@ using Microsoft.Extensions.Logging;
using Opc.Ua;
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;
@@ -35,6 +41,12 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
private FolderState? _driverRoot;
private readonly Dictionary<string, BaseDataVariableState> _variablesByFullRef = new(StringComparer.OrdinalIgnoreCase);
// PR 26: SecurityClassification per variable, populated during Variable() registration.
// OnWriteValue looks up the classification here to gate the write by the session's roles.
// Drivers never enforce authz themselves — the classification is discovery-time metadata
// only (feedback_acl_at_server_layer.md).
private readonly Dictionary<string, SecurityClassification> _securityByFullRef = new(StringComparer.OrdinalIgnoreCase);
// Active building folder — set per Folder() call so Variable() lands under the right parent.
// A stack would support nested folders; we use a single current folder because IAddressSpaceBuilder
// returns a child builder per Folder call and the caller threads nesting through those references.
@@ -64,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.
@@ -115,13 +133,21 @@ 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);
AddPredefinedNode(SystemContext, v);
_variablesByFullRef[attributeInfo.FullName] = v;
_securityByFullRef[attributeInfo.FullName] = attributeInfo.SecurityClass;
v.OnReadValue = OnReadValue;
v.OnWriteValue = OnWriteValue;
@@ -337,6 +363,22 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
var fullRef = node.NodeId.Identifier as string;
if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown;
// PR 26: server-layer write authorization. Look up the attribute's classification
// (populated during Variable() in Discover) and check the session's roles against the
// policy table. Drivers don't participate in this decision — IWritable.WriteAsync
// never sees a request we'd have refused here.
if (_securityByFullRef.TryGetValue(fullRef!, out var classification))
{
var roles = context.UserIdentity is IRoleBearer rb ? rb.Roles : [];
if (!WriteAuthzPolicy.IsAllowed(classification, roles))
{
_logger.LogInformation(
"Write denied for {FullRef}: classification={Classification} userRoles=[{Roles}]",
fullRef, classification, string.Join(",", roles));
return new ServiceResult(StatusCodes.BadUserAccessDenied);
}
}
try
{
var results = _writable.WriteAsync(
@@ -360,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

@@ -97,7 +97,7 @@ public sealed class OtOpcUaServer : StandardServer
/// managers can gate writes by role via <c>session.Identity</c>. Anonymous identity still
/// uses the stack's default.
/// </summary>
private sealed class RoleBasedIdentity : UserIdentity
private sealed class RoleBasedIdentity : UserIdentity, IRoleBearer
{
public IReadOnlyList<string> Roles { get; }
public string? Display { get; }

View File

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

View File

@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary>
/// Minimal interface a <see cref="Opc.Ua.IUserIdentity"/> implementation can expose so
/// <see cref="ZB.MOM.WW.OtOpcUa.Server.OpcUa.DriverNodeManager"/> can read the session's
/// resolved roles without a hard dependency on any specific identity subtype. Implemented
/// by <c>OtOpcUaServer.RoleBasedIdentity</c>; tests implement it with stub identities to
/// drive the authz policy under different role sets.
/// </summary>
public interface IRoleBearer
{
IReadOnlyList<string> Roles { get; }
}

View File

@@ -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=&lt;Group&gt;,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=&lt;name&gt;,&lt;SearchBase&gt;</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

View File

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

View File

@@ -0,0 +1,70 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary>
/// Server-layer write-authorization policy. ACL enforcement lives here — drivers report
/// <see cref="SecurityClassification"/> as discovery metadata only; the server decides
/// whether a given session is allowed to write a given attribute by checking the session's
/// roles (resolved at login via <see cref="LdapUserAuthenticator"/>) against the required
/// role for the attribute's classification.
/// </summary>
/// <remarks>
/// Matches the table in <c>docs/Configuration.md</c>:
/// <list type="bullet">
/// <item><c>FreeAccess</c>: no role required — anonymous sessions can write (matches v1 default).</item>
/// <item><c>Operate</c> / <c>SecuredWrite</c>: <c>WriteOperate</c> role required.</item>
/// <item><c>Tune</c>: <c>WriteTune</c> role required.</item>
/// <item><c>VerifiedWrite</c> / <c>Configure</c>: <c>WriteConfigure</c> role required.</item>
/// <item><c>ViewOnly</c>: no role grants write access.</item>
/// </list>
/// <c>AlarmAck</c> is checked at the alarm-acknowledge path, not here.
/// </remarks>
public static class WriteAuthzPolicy
{
public const string RoleWriteOperate = "WriteOperate";
public const string RoleWriteTune = "WriteTune";
public const string RoleWriteConfigure = "WriteConfigure";
/// <summary>
/// Decide whether a session with <paramref name="userRoles"/> is allowed to write to an
/// attribute with the given <paramref name="classification"/>. Returns true for
/// <c>FreeAccess</c> regardless of roles (including empty / anonymous sessions) and
/// false for <c>ViewOnly</c> regardless of roles. Every other classification requires
/// the session to carry the mapped role — case-insensitive match.
/// </summary>
public static bool IsAllowed(SecurityClassification classification, IReadOnlyCollection<string> userRoles)
{
if (classification == SecurityClassification.FreeAccess) return true;
if (classification == SecurityClassification.ViewOnly) return false;
var required = RequiredRole(classification);
if (required is null) return false;
foreach (var r in userRoles)
{
if (string.Equals(r, required, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <summary>
/// Required role for a classification, or null when no role grants access
/// (<see cref="SecurityClassification.ViewOnly"/>) or no role is needed
/// (<see cref="SecurityClassification.FreeAccess"/> — also returns null; callers use
/// <see cref="IsAllowed"/> which handles the special-cases rather than branching on
/// null themselves).
/// </summary>
public static string? RequiredRole(SecurityClassification classification) => classification switch
{
SecurityClassification.FreeAccess => null, // IsAllowed short-circuits
SecurityClassification.Operate => RoleWriteOperate,
SecurityClassification.SecuredWrite => RoleWriteOperate,
SecurityClassification.Tune => RoleWriteTune,
SecurityClassification.VerifiedWrite => RoleWriteConfigure,
SecurityClassification.Configure => RoleWriteConfigure,
SecurityClassification.ViewOnly => null, // IsAllowed short-circuits
_ => null,
};
}

View File

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

View File

@@ -0,0 +1,153 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class CertTrustServiceTests : IDisposable
{
private readonly string _root;
public CertTrustServiceTests()
{
_root = Path.Combine(Path.GetTempPath(), $"otopcua-cert-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path.Combine(_root, "rejected", "certs"));
Directory.CreateDirectory(Path.Combine(_root, "trusted", "certs"));
}
public void Dispose()
{
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
}
private CertTrustService Service() => new(
Options.Create(new CertTrustOptions { PkiStoreRoot = _root }),
NullLogger<CertTrustService>.Instance);
private X509Certificate2 WriteTestCert(CertStoreKind kind, string subject)
{
using var rsa = RSA.Create(2048);
var req = new CertificateRequest($"CN={subject}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
var dir = Path.Combine(_root, kind == CertStoreKind.Rejected ? "rejected" : "trusted", "certs");
var path = Path.Combine(dir, $"{subject} [{cert.Thumbprint}].der");
File.WriteAllBytes(path, cert.Export(X509ContentType.Cert));
return cert;
}
[Fact]
public void ListRejected_returns_parsed_cert_info_for_each_der_in_rejected_certs_dir()
{
var c = WriteTestCert(CertStoreKind.Rejected, "test-client-A");
var rows = Service().ListRejected();
rows.Count.ShouldBe(1);
rows[0].Thumbprint.ShouldBe(c.Thumbprint);
rows[0].Subject.ShouldContain("test-client-A");
rows[0].Store.ShouldBe(CertStoreKind.Rejected);
}
[Fact]
public void ListTrusted_is_separate_from_rejected()
{
WriteTestCert(CertStoreKind.Rejected, "rej");
WriteTestCert(CertStoreKind.Trusted, "trust");
var svc = Service();
svc.ListRejected().Count.ShouldBe(1);
svc.ListTrusted().Count.ShouldBe(1);
svc.ListRejected()[0].Subject.ShouldContain("rej");
svc.ListTrusted()[0].Subject.ShouldContain("trust");
}
[Fact]
public void TrustRejected_moves_file_from_rejected_to_trusted()
{
var c = WriteTestCert(CertStoreKind.Rejected, "promoteme");
var svc = Service();
svc.TrustRejected(c.Thumbprint).ShouldBeTrue();
svc.ListRejected().ShouldBeEmpty();
var trusted = svc.ListTrusted();
trusted.Count.ShouldBe(1);
trusted[0].Thumbprint.ShouldBe(c.Thumbprint);
}
[Fact]
public void TrustRejected_returns_false_when_thumbprint_not_in_rejected()
{
var svc = Service();
svc.TrustRejected("00DEADBEEF00DEADBEEF00DEADBEEF00DEADBEEF").ShouldBeFalse();
}
[Fact]
public void DeleteRejected_removes_the_file()
{
var c = WriteTestCert(CertStoreKind.Rejected, "killme");
var svc = Service();
svc.DeleteRejected(c.Thumbprint).ShouldBeTrue();
svc.ListRejected().ShouldBeEmpty();
}
[Fact]
public void UntrustCert_removes_from_trusted_only()
{
var c = WriteTestCert(CertStoreKind.Trusted, "revoke");
var svc = Service();
svc.UntrustCert(c.Thumbprint).ShouldBeTrue();
svc.ListTrusted().ShouldBeEmpty();
}
[Fact]
public void Thumbprint_match_is_case_insensitive()
{
var c = WriteTestCert(CertStoreKind.Rejected, "case");
var svc = Service();
// X509Certificate2.Thumbprint is upper-case hex; operators pasting from logs often
// lowercase it. IsAllowed-style case-insensitive match keeps the UX forgiving.
svc.TrustRejected(c.Thumbprint.ToLowerInvariant()).ShouldBeTrue();
}
[Fact]
public void Missing_store_directories_produce_empty_lists_not_exceptions()
{
// Fresh root with no certs subfolder — service should tolerate a pristine install.
var altRoot = Path.Combine(Path.GetTempPath(), $"otopcua-cert-empty-{Guid.NewGuid():N}");
try
{
var svc = new CertTrustService(
Options.Create(new CertTrustOptions { PkiStoreRoot = altRoot }),
NullLogger<CertTrustService>.Instance);
svc.ListRejected().ShouldBeEmpty();
svc.ListTrusted().ShouldBeEmpty();
}
finally
{
if (Directory.Exists(altRoot)) Directory.Delete(altRoot, recursive: true);
}
}
[Fact]
public void Malformed_file_is_skipped_not_fatal()
{
// Drop junk bytes that don't parse as a cert into the rejected/certs directory. The
// service must skip it and still return the valid certs — one bad file can't take the
// whole management page offline.
File.WriteAllText(Path.Combine(_root, "rejected", "certs", "junk.der"), "not a cert");
var c = WriteTestCert(CertStoreKind.Rejected, "valid");
var rows = Service().ListRejected();
rows.Count.ShouldBe(1);
rows[0].Thumbprint.ShouldBe(c.Thumbprint);
}
}

View File

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

View File

@@ -28,6 +28,7 @@ public sealed class SchemaComplianceTests
"Namespace", "UnsArea", "UnsLine",
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup",
"NodeAcl", "ExternalIdReservation",
"DriverHostStatus",
};
var actual = QueryStrings(@"

View File

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

View File

@@ -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]

View File

@@ -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. -->

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,118 @@
{
"_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],
[2048, 2050],
[3072, 3074],
[4000, 4007],
[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": "Y0 marker. DL260 maps Y0 to coil 2048 (0-based). Coil 2048 = ON proves the mapping.",
"addr": 2048, "value": 1},
{"addr": 2049, "value": 0},
{"addr": 2050, "value": 1},
{"_quirk": "C0 marker. DL260 maps C0 to coil 3072 (0-based). Coil 3072 = ON proves the mapping.",
"addr": 3072, "value": 1},
{"addr": 3073, "value": 0},
{"addr": 3074, "value": 1},
{"_quirk": "Scratch C-relays for write-roundtrip tests against the writable C range.",
"addr": 4000, "value": 0},
{"addr": 4001, "value": 0},
{"addr": 4002, "value": 0},
{"addr": 4003, "value": 0},
{"addr": 4004, "value": 0},
{"addr": 4005, "value": 0},
{"addr": 4006, "value": 0},
{"addr": 4007, "value": 0}
],
"uint32": [],
"float32": [],
"string": [],
"repeat": []
}
}
}

View File

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

View File

@@ -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": []
}
}
}

View File

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

View File

@@ -0,0 +1,77 @@
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));
}
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,134 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class WriteAuthzPolicyTests
{
// --- FreeAccess and ViewOnly special-cases ---
[Fact]
public void FreeAccess_allows_write_even_for_empty_role_set()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.FreeAccess, []).ShouldBeTrue();
}
[Fact]
public void FreeAccess_allows_write_for_arbitrary_roles()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.FreeAccess, ["SomeOtherRole"]).ShouldBeTrue();
}
[Fact]
public void ViewOnly_denies_write_even_with_every_role()
{
var allRoles = new[] { "WriteOperate", "WriteTune", "WriteConfigure", "AlarmAck" };
WriteAuthzPolicy.IsAllowed(SecurityClassification.ViewOnly, allRoles).ShouldBeFalse();
}
// --- Operate tier ---
[Fact]
public void Operate_requires_WriteOperate_role()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["WriteOperate"]).ShouldBeTrue();
}
[Fact]
public void Operate_role_match_is_case_insensitive()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["writeoperate"]).ShouldBeTrue();
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["WRITEOPERATE"]).ShouldBeTrue();
}
[Fact]
public void Operate_denies_empty_role_set()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, []).ShouldBeFalse();
}
[Fact]
public void Operate_denies_wrong_role()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["ReadOnly"]).ShouldBeFalse();
}
[Fact]
public void SecuredWrite_maps_to_same_WriteOperate_requirement_as_Operate()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.SecuredWrite, ["WriteOperate"]).ShouldBeTrue();
WriteAuthzPolicy.IsAllowed(SecurityClassification.SecuredWrite, ["WriteTune"]).ShouldBeFalse();
}
// --- Tune tier ---
[Fact]
public void Tune_requires_WriteTune_role()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, ["WriteTune"]).ShouldBeTrue();
}
[Fact]
public void Tune_denies_WriteOperate_only_session()
{
// Important: role roles do NOT cascade — a session with WriteOperate can't write a Tune
// attribute. Operators escalate by adding WriteTune to the session's roles, not by a
// hierarchy the policy infers on its own.
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, ["WriteOperate"]).ShouldBeFalse();
}
// --- Configure tier ---
[Fact]
public void Configure_requires_WriteConfigure_role()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Configure, ["WriteConfigure"]).ShouldBeTrue();
}
[Fact]
public void VerifiedWrite_maps_to_same_WriteConfigure_requirement_as_Configure()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.VerifiedWrite, ["WriteConfigure"]).ShouldBeTrue();
WriteAuthzPolicy.IsAllowed(SecurityClassification.VerifiedWrite, ["WriteOperate"]).ShouldBeFalse();
}
// --- Multi-role sessions ---
[Fact]
public void Session_with_multiple_roles_is_allowed_when_any_matches()
{
var roles = new[] { "ReadOnly", "WriteTune", "AlarmAck" };
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, roles).ShouldBeTrue();
}
[Fact]
public void Session_with_only_unrelated_roles_is_denied()
{
var roles = new[] { "ReadOnly", "AlarmAck", "SomeCustomRole" };
WriteAuthzPolicy.IsAllowed(SecurityClassification.Configure, roles).ShouldBeFalse();
}
// --- Mapping table ---
[Theory]
[InlineData(SecurityClassification.Operate, WriteAuthzPolicy.RoleWriteOperate)]
[InlineData(SecurityClassification.SecuredWrite, WriteAuthzPolicy.RoleWriteOperate)]
[InlineData(SecurityClassification.Tune, WriteAuthzPolicy.RoleWriteTune)]
[InlineData(SecurityClassification.VerifiedWrite, WriteAuthzPolicy.RoleWriteConfigure)]
[InlineData(SecurityClassification.Configure, WriteAuthzPolicy.RoleWriteConfigure)]
public void RequiredRole_returns_expected_role_for_classification(SecurityClassification c, string expected)
{
WriteAuthzPolicy.RequiredRole(c).ShouldBe(expected);
}
[Theory]
[InlineData(SecurityClassification.FreeAccess)]
[InlineData(SecurityClassification.ViewOnly)]
public void RequiredRole_returns_null_for_special_classifications(SecurityClassification c)
{
WriteAuthzPolicy.RequiredRole(c).ShouldBeNull();
}
}