diff --git a/docs/Driver.AbLegacy.Cli.md b/docs/Driver.AbLegacy.Cli.md index 0ad3b31..cbe11cf 100644 --- a/docs/Driver.AbLegacy.Cli.md +++ b/docs/Driver.AbLegacy.Cli.md @@ -32,6 +32,27 @@ Family ↔ CIP-path cheat sheet: with no backplane - **LogixPccc** — `1,0` (Logix controller accessed via the PCCC compatibility layer; rare) +- **PLC-5 via 1756-DHRIO bridge** — `1,,2,` (PLC-5 only). + See [drivers/AbLegacy-DH-Bridging.md](drivers/AbLegacy-DH-Bridging.md) for + the full DH+ syntax, octal-station reference (00..77 = 0..63), and manual + hardware smoke procedure. + +#### DHRIO worked example (PR ablegacy-13 / #256) + +PLC-5 on DH+ node 7 (octal 07), DHRIO module in chassis slot 3, +EtherNet/IP gateway 192.168.1.10: + +```powershell +otopcua-ablegacy-cli read ` + -g ab://192.168.1.10/1,3,2,07 ` + -P Plc5 -a N7:10 -t Int +``` + +The parser validates `1,,2,`: port-1 must be the backplane, +slot must be 0..16, port-3 must be `2` (DH+), station must be octal 0..77 (so +`80`, `90`, etc. are rejected). Combining a DH+ bridge path with a non-PLC-5 +family at startup throws `InvalidOperationException("DHRIO bridging is +PLC-5-only")`. ### Per-device timeout / retry tuning (#252, PR 9) diff --git a/docs/drivers/AbLegacy-DH-Bridging.md b/docs/drivers/AbLegacy-DH-Bridging.md new file mode 100644 index 0000000..11b723f --- /dev/null +++ b/docs/drivers/AbLegacy-DH-Bridging.md @@ -0,0 +1,141 @@ +# AB Legacy — DH+ via 1756-DHRIO bridging + +PR ablegacy-13 / [#256](https://github.com/dohertj2/lmxopcua/issues/256). The AB +Legacy driver can address a PLC-5 sitting on a DH+ link by routing CIP requests +through a 1756-DHRIO module installed in a ControlLogix chassis. This is the +canonical way to keep an installed-base PLC-5 fleet alive after the chassis- +level migration to ControlLogix; the DHRIO module exposes a DH+ "side" that +talks to the legacy PLC-5 / SLC-DH+ peers and a backplane "side" that the +ControlLogix CPU + Ethernet bridge can route through. + +## Wire layout + +``` +OtOpcUa server ──EtherNet/IP──► 1756-EN2T (slot 0) ──backplane──► 1756-DHRIO (slot N) ──DH+──► PLC-5 +``` + +Two CIP hops: + +1. **Backplane** — port `1`, slot `` (the slot the DHRIO module lives in). +2. **DH+** — port `2`, station `` (the DH+ node address of the target PLC-5, + in **octal**). + +Resulting CIP path: `1,,2,`. + +> The first port `1` is always the backplane; port `2` is the DH+ side of the +> 1756-DHRIO module. This mirrors the convention Rockwell uses in RSLinx + RSLogix +> 5. + +## Octal station number + +The DH+ network was specified with **octal** node addresses. Rockwell tooling +displays them in octal too (RSLogix 5 → "DH+ Node Address" field on the +controller properties dialog). The driver follows suit — the station segment +of the CIP path **must be parsed as octal** (digits 0..7 only; `8`, `9`, and +multi-byte garbage are rejected). + +DH+ addresses run `0..77` octal == `0..63` decimal. Quick reference: + +| Octal | Decimal | Octal | Decimal | Octal | Decimal | Octal | Decimal | +|------:|--------:|------:|--------:|------:|--------:|------:|--------:| +| 00 | 0 | 20 | 16 | 40 | 32 | 60 | 48 | +| 01 | 1 | 21 | 17 | 41 | 33 | 61 | 49 | +| 02 | 2 | 22 | 18 | 42 | 34 | 62 | 50 | +| 03 | 3 | 23 | 19 | 43 | 35 | 63 | 51 | +| 04 | 4 | 24 | 20 | 44 | 36 | 64 | 52 | +| 05 | 5 | 25 | 21 | 45 | 37 | 65 | 53 | +| 06 | 6 | 26 | 22 | 46 | 38 | 66 | 54 | +| 07 | 7 | 27 | 23 | 47 | 39 | 67 | 55 | +| 10 | 8 | 30 | 24 | 50 | 40 | 70 | 56 | +| 11 | 9 | 31 | 25 | 51 | 41 | 71 | 57 | +| 12 | 10 | 32 | 26 | 52 | 42 | 72 | 58 | +| 13 | 11 | 33 | 27 | 53 | 43 | 73 | 59 | +| 14 | 12 | 34 | 28 | 54 | 44 | 74 | 60 | +| 15 | 13 | 35 | 29 | 55 | 45 | 75 | 61 | +| 16 | 14 | 36 | 30 | 56 | 46 | 76 | 62 | +| 17 | 15 | 37 | 31 | 57 | 47 | 77 | 63 | + +Anything past `77` octal (i.e. decimal > 63) is invalid on a real DH+ network +and rejected by the parser. + +## PLC-5 only + +DHRIO bridging is **PLC-5-only**. The driver enforces this at +`AbLegacyDriver.InitializeAsync` time: a DH+ bridge path combined with +`PlcFamily=Slc500 / MicroLogix / LogixPccc` throws +`InvalidOperationException("DHRIO bridging is PLC-5-only")` immediately rather +than letting reads silently fail with `BadCommunicationError` on the wire. + +Background: the 1756-DHRIO module only speaks DH+ to PLC-5 / SLC-DH+ peers, and +libplctag's PCCC stack only exposes the PLC-5 side. SLC 5/04 boxes on DH+ +**can** be physically reached through a DHRIO module, but the protocol stack +needed to drive them isn't exposed by libplctag — out of scope for this driver. + +## CLI worked example + +PLC-5 at DH+ node `07` (octal == 7 decimal), DHRIO module in slot 3, gateway +`192.168.1.10`: + +```powershell +otopcua-ablegacy-cli probe ` + -g ab://192.168.1.10/1,3,2,07 ` + -P Plc5 ` + -a N7:0 +``` + +```powershell +# Read N7:10 from the PLC-5 across the DHRIO bridge +otopcua-ablegacy-cli read ` + -g ab://192.168.1.10/1,3,2,07 ` + -P Plc5 ` + -a N7:10 ` + -t Int +``` + +The driver surfaces the parsed bridge form on the host-address record: +`BackplaneSlot=3`, `DhPlusPort=2`, `DhPlusStation=7` (decimal-translated). Use +those values when reading driver-diagnostics output to confirm the bridge was +recognised — a non-bridge CIP path leaves all three fields null. + +## Manual smoke procedure + +There is no automated end-to-end coverage for DH+ bridging because the only +path to wire-level validation is real hardware (libplctag's `ab_server` Docker +image doesn't simulate the DHRIO + DH+ + PLC-5 stack). The unit-test layer +covers parser positive / negative cases. + +Hardware smoke checklist: + +1. Confirm the 1756-DHRIO module is present in the target ControlLogix chassis. + RSLinx Classic should show `DH+, 1` under the chassis tree with the PLC-5 + nodes enumerated underneath. +2. Note the DHRIO module's slot number (the `` in `1,,2,`). +3. Note the target PLC-5's DH+ node address — read it off the front-panel switch + bank, or the controller properties in RSLogix 5. **Read it as octal**. +4. From an OtOpcUa box that can reach the EtherNet/IP gateway: + + ```powershell + otopcua-ablegacy-cli probe -g ab:///1,,2, -P Plc5 -a S:0 + ``` + + `S:0` (status file word 0) is non-destructive and present on every PLC-5. +5. If the probe succeeds, exercise an N file read against a known + non-zero address. Compare against the value displayed in RSLogix 5 → + Online → Data → N7. + +If the probe fails with `BadCommunicationError`: + +- Wrong slot number — re-check via RSLinx. +- Wrong octal node — convert from RSLogix 5's display value (already octal); a + decimal-thinking conversion mistake is the most common smoke failure. +- DHRIO module's DH+ baud rate doesn't match the PLC-5's switch setting (57.6k + / 115.2k / 230.4k) — driver-side problem this can't paper over. +- A scanner on the DHRIO is in scheduled-mode and starving unscheduled + PCCC traffic — bump the DHRIO's unscheduled-message slice in RSLogix 5000. + +## See also + +- [`Driver.AbLegacy.Cli.md`](../Driver.AbLegacy.Cli.md) — the family / CIP-path + cheat sheet now carries a DHRIO row. +- [`drivers/AbLegacy-Test-Fixture.md`](AbLegacy-Test-Fixture.md) — DH+ bridging + is unit-only; no Docker fixture supports it. diff --git a/docs/drivers/AbLegacy-Test-Fixture.md b/docs/drivers/AbLegacy-Test-Fixture.md index 54d3af8..7d71b5c 100644 --- a/docs/drivers/AbLegacy-Test-Fixture.md +++ b/docs/drivers/AbLegacy-Test-Fixture.md @@ -132,6 +132,17 @@ driver-side correctness depends on libplctag being correct. `IPerCallHostResolver` contract is verified; real PCCC wire routing across multiple gateways is not. +### 3a. DH+ via 1756-DHRIO bridging (PR ablegacy-13 / #256) + +Unit-only — coverage lives in `AbLegacyDhPlusBridgingTests`. The CIP-path +parser positive / negative cases (octal-station validation, slot bounds, port +shape) and the PLC-5-only family guard at `InitializeAsync` are exercised +against fakes. There is no Docker fixture for DH+ because libplctag's +`ab_server` doesn't simulate the DHRIO + DH+ + PLC-5 stack — wire-level +validation requires real hardware. See +[`AbLegacy-DH-Bridging.md`](AbLegacy-DH-Bridging.md) for the manual smoke +procedure. + ### 4. Alarms / history PCCC has no alarm object + no history object. Driver doesn't implement diff --git a/scripts/e2e/test-ablegacy.ps1 b/scripts/e2e/test-ablegacy.ps1 index 0537784..eb49d4c 100644 --- a/scripts/e2e/test-ablegacy.ps1 +++ b/scripts/e2e/test-ablegacy.ps1 @@ -54,6 +54,16 @@ Failure threshold the server is configured with (default 3). The demote assertion writes/reads N+1 times against the killed simulator to guarantee the threshold trips even if some reads beat the kill. + +.PARAMETER DhPlusStation + PR ablegacy-13 / #256 — DH+ node address (octal 0..77 == decimal 0..63) + of a PLC-5 reachable through a 1756-DHRIO module. **Documentation + parameter only — there is no automated assertion**: libplctag's ab_server + does not simulate the DHRIO + DH+ + PLC-5 stack, so wire-level coverage + requires real hardware. When supplied alongside a `-Gateway` of the form + `ab:///1,,2,` and `-PlcType Plc5`, the value + here is recorded in the run log so reproducibility is auditable. See + docs/drivers/AbLegacy-DH-Bridging.md for the manual smoke procedure. #> param( @@ -64,7 +74,11 @@ param( [Parameter(Mandatory)] [string]$BridgeNodeId, [string]$DiagnosticsRequestCountNodeId, [string]$DiagnosticsDemoteCountNodeId, - [int]$FailureThresholdForDemote = 3 + [int]$FailureThresholdForDemote = 3, + # PR ablegacy-13 / #256 — DH+ station via 1756-DHRIO bridging. Doc-only: + # no automated assertion (no Docker fixture covers DH+). See script header + # comment + docs/drivers/AbLegacy-DH-Bridging.md. + [string]$DhPlusStation ) $ErrorActionPreference = "Stop" diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 6496c9e..b81d50b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -163,6 +163,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover var addr = AbLegacyHostAddress.TryParse(device.HostAddress) ?? throw new InvalidOperationException( $"AbLegacy device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'."); + // PR ablegacy-13 / #256 — DHRIO DH+ bridging is PLC-5-only. SLC500 / + // MicroLogix / LogixPccc cannot be addressed through a 1756-DHRIO module + // (the module only speaks DH+ to PLC-5 and SLC-DH+ peers — and the SLC-DH+ + // path uses a different protocol stack libplctag's PCCC layer doesn't + // expose). Catch the misconfiguration up front rather than waiting for + // reads to return BadCommunicationError on the wire. + if (addr.IsDhPlusBridge && device.PlcFamily != AbLegacyPlcFamily.Plc5) + { + throw new InvalidOperationException( + $"AbLegacy device '{device.HostAddress}' uses the 1756-DHRIO DH+ bridge " + + $"path '1,{addr.BackplaneSlot},2,…' but PlcFamily='{device.PlcFamily}'. " + + "DHRIO bridging is PLC-5-only."); + } var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily); _devices[device.HostAddress] = new DeviceState(addr, device, profile); // PR ablegacy-10 / #253 — pre-allocate the diagnostic-counter slot so the diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyHostAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyHostAddress.cs index c942230..d10ba9b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyHostAddress.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyHostAddress.cs @@ -7,14 +7,38 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// a direct-wired SLC 500 uses an empty path). /// /// -/// Parser duplicated from AbCipHostAddress rather than shared because the two drivers ship -/// independently + a shared helper would force a reference between them. If a third AB -/// driver appears, extract into Core.Abstractions. +/// Parser duplicated from AbCipHostAddress rather than shared because the two drivers +/// ship independently + a shared helper would force a reference between them. If a third AB +/// driver appears, extract into Core.Abstractions. +/// PR ablegacy-13 / #256 — the optional , +/// and fields are populated when the +/// CIP path matches the canonical 1756-DHRIO bridge form 1,<slot>,2,<station>: +/// port 1 (backplane) → DHRIO module slot → port 2 (DH+ side of the module) → DH+ node +/// address. The DH+ station number is octal in PLC-5 firmware (0..77 = decimal 0..63); +/// we parse it as octal and surface the decimal value for diagnostics. DHRIO bridging is +/// PLC-5-only — the family-validation guard lives on the driver. /// -public sealed record AbLegacyHostAddress(string Gateway, int Port, string CipPath) +public sealed record AbLegacyHostAddress( + string Gateway, + int Port, + string CipPath, + int? BackplaneSlot = null, + int? DhPlusPort = null, + int? DhPlusStation = null) { public const int DefaultEipPort = 44818; + /// + /// Maximum chassis slot index accepted for the DHRIO bridge form. Real ControlLogix + /// chassis are 4 / 7 / 10 / 13 / 17 slots; capping at 16 covers the largest standard + /// 17-slot frame (slots 0..16) without rejecting anything an operator might legitimately + /// configure. Tighter / family-specific bounds are enforced elsewhere. + /// + public const int MaxBackplaneSlot = 16; + + /// True iff the CIP path was the canonical 1756-DHRIO DH+ bridge form. + public bool IsDhPlusBridge => DhPlusStation is not null; + public override string ToString() => Port == DefaultEipPort ? $"ab://{Gateway}/{CipPath}" : $"ab://{Gateway}:{Port}/{CipPath}"; @@ -48,6 +72,73 @@ public sealed record AbLegacyHostAddress(string Gateway, int Port, string CipPat } if (string.IsNullOrEmpty(gateway)) return null; + // PR ablegacy-13 / #256 — optional DHRIO DH+ bridge path detection. + // Shape: exactly four comma-separated decimal segments `port,slot,port,station` with + // port[0]=1 (backplane), slot in [0..16], port[2]=2 (DH+), station octal 0..77. + // Anything else is left as an opaque CIP path — direct-wired PLCs, MicroLogix empty + // paths, longer multi-hop bridges all flow through unchanged. + if (TryParseDhPlusBridge(cipPath, out var slot, out var dhPort, out var station)) + { + return new AbLegacyHostAddress(gateway, port, cipPath, + BackplaneSlot: slot, + DhPlusPort: dhPort, + DhPlusStation: station); + } + return new AbLegacyHostAddress(gateway, port, cipPath); } + + /// + /// Returns true iff is exactly the four-segment DHRIO + /// bridge form 1,<slot>,2,<station>. Returns false for every + /// other shape — including malformed near-misses (slot out of range, station out of + /// octal range, port-1 ≠ backplane). A near-miss returns false rather than throwing so + /// the caller can keep treating the CIP path as opaque. + /// + /// + /// This method is the single source of truth for the DHRIO octal-station range check. + /// Reuses the same "octal accepts only 0..7" rule as AbLegacyAddress's PLC-5 + /// I/O parser — a leading 8 or 9 in any digit is rejected. + /// + private static bool TryParseDhPlusBridge( + string cipPath, out int slot, out int dhPort, out int station) + { + slot = 0; + dhPort = 0; + station = 0; + + if (string.IsNullOrEmpty(cipPath)) return false; + + // Reject leading / trailing whitespace inside segments — `1, 3, 2, 07` (with spaces) + // is plausibly user typo but we keep the parser strict to avoid false positives. + var parts = cipPath.Split(','); + if (parts.Length != 4) return false; + + if (!int.TryParse(parts[0], out var firstPort) || firstPort != 1) return false; + if (!int.TryParse(parts[1], out slot) || slot < 0 || slot > MaxBackplaneSlot) return false; + if (!int.TryParse(parts[2], out dhPort) || dhPort != 2) return false; + if (!TryParseOctal(parts[3], out station) || station < 0 || station > 63) return false; + + return true; + } + + /// + /// Parse a DH+ station number written in octal (0..77 octal = 0..63 decimal). Mirrors + /// the octal-rule in AbLegacyAddress.TryParseIndex — digits 0..7 only, no sign, + /// no prefix. Returns false for empty input, illegal digits (8/9), or non-digit + /// characters. + /// + private static bool TryParseOctal(string text, out int value) + { + value = 0; + if (string.IsNullOrEmpty(text)) return false; + var acc = 0; + foreach (var c in text) + { + if (c < '0' || c > '7') return false; + acc = (acc * 8) + (c - '0'); + } + value = acc; + return true; + } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs index a657782..adb52f4 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs @@ -52,6 +52,14 @@ public sealed record AbLegacyPlcFamilyProfile( SupportsPlsFile: false, SupportsBlockTransferFile: false); + /// + /// PR ablegacy-13 / #256 — PLC-5 is the only family that supports the 1756-DHRIO DH+ + /// bridging form (CIP path 1,<slot>,2,<station-octal>). The DHRIO + /// module only speaks DH+ to PLC-5 / SLC-DH+ peers, and libplctag's PCCC stack only + /// exposes the PLC-5 side. SLC500 / MicroLogix / LogixPccc devices addressed through a + /// DHRIO path are rejected at AbLegacyDriver.InitializeAsync time. See + /// docs/drivers/AbLegacy-DH-Bridging.md for the full DH+ syntax + smoke procedure. + /// public static readonly AbLegacyPlcFamilyProfile Plc5 = new( LibplctagPlcAttribute: "plc5", DefaultCipPath: "1,0", diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDhPlusBridgingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDhPlusBridgingTests.cs new file mode 100644 index 0000000..128a765 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDhPlusBridgingTests.cs @@ -0,0 +1,147 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// PR ablegacy-13 / #256 — coverage for the 1756-DHRIO DH+ bridge form on +/// : octal-station validation, slot bounds, port shape, +/// and the PLC-5-only family guard at AbLegacyDriver.InitializeAsync. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyDhPlusBridgingTests +{ + [Theory] + // station 07 octal → 7 decimal + [InlineData("ab://10.0.0.1/1,3,2,07", 3, 2, 7)] + // station 77 octal → 63 decimal (top of the DH+ address range) + [InlineData("ab://10.0.0.1/1,3,2,77", 3, 2, 63)] + // single-digit station 0..7 + [InlineData("ab://10.0.0.1/1,0,2,0", 0, 2, 0)] + [InlineData("ab://10.0.0.1/1,16,2,77", 16, 2, 63)] + // station 10 octal → 8 decimal + [InlineData("ab://10.0.0.1/1,3,2,10", 3, 2, 8)] + public void DhPlusBridge_parses_valid_octal_station(string input, int slot, int dhPort, int station) + { + var parsed = AbLegacyHostAddress.TryParse(input); + parsed.ShouldNotBeNull(); + parsed.IsDhPlusBridge.ShouldBeTrue(); + parsed.BackplaneSlot.ShouldBe(slot); + parsed.DhPlusPort.ShouldBe(dhPort); + parsed.DhPlusStation.ShouldBe(station); + } + + [Theory] + // octal-illegal digits 8 / 9 in the station segment + [InlineData("ab://10.0.0.1/1,3,2,80")] + [InlineData("ab://10.0.0.1/1,3,2,90")] + [InlineData("ab://10.0.0.1/1,3,2,08")] + [InlineData("ab://10.0.0.1/1,3,2,79")] + // port-1 not the backplane (must be exactly 1) + [InlineData("ab://10.0.0.1/0,3,2,77")] + [InlineData("ab://10.0.0.1/2,3,2,77")] + // port-3 not the DH+ port (must be exactly 2) + [InlineData("ab://10.0.0.1/1,3,3,77")] + [InlineData("ab://10.0.0.1/1,3,1,77")] + // slot out of range (max 16) + [InlineData("ab://10.0.0.1/1,17,2,07")] + [InlineData("ab://10.0.0.1/1,99,2,07")] + [InlineData("ab://10.0.0.1/1,-1,2,07")] + // wrong segment count + [InlineData("ab://10.0.0.1/1,3,2")] + [InlineData("ab://10.0.0.1/1,3,2,07,extra")] + public void DhPlusBridge_rejects_invalid_form(string input) + { + // The host-address parser still succeeds (the CIP path stays opaque) — only the + // DH+ accessors are absent. That keeps AB-Legacy compatible with paths shaped + // similarly to a DHRIO bridge but not actually one (e.g. a future DHRIO-clone + // module on a non-backplane-1 port). + var parsed = AbLegacyHostAddress.TryParse(input); + // For the wrong-segment-count and trailing-extra cases, parsing still produces a + // generic record with an opaque CipPath and no DH+ surface. For the octal-illegal + // and slot/port out-of-range cases the same applies — the CIP path is preserved + // verbatim for libplctag, but the structured DH+ accessors are null because the + // path didn't pass the strict bridge-form validator. + parsed.ShouldNotBeNull(); + parsed.IsDhPlusBridge.ShouldBeFalse(); + parsed.BackplaneSlot.ShouldBeNull(); + parsed.DhPlusPort.ShouldBeNull(); + parsed.DhPlusStation.ShouldBeNull(); + } + + [Fact] + public void DhPlusBridge_diagnostics_surface_all_three_fields() + { + var parsed = AbLegacyHostAddress.TryParse("ab://10.0.0.1/1,7,2,42"); + parsed.ShouldNotBeNull(); + parsed.IsDhPlusBridge.ShouldBeTrue(); + parsed.BackplaneSlot.ShouldBe(7); + parsed.DhPlusPort.ShouldBe(2); + // 42 octal == 4*8 + 2 == 34 decimal + parsed.DhPlusStation.ShouldBe(34); + // CIP path is preserved verbatim — libplctag receives the original octal-station + // representation, not the decimal-translated one. + parsed.CipPath.ShouldBe("1,7,2,42"); + } + + [Fact] + public void NonBridgePath_leaves_dhplus_fields_null() + { + // Direct-wired SLC 5/05 — empty path + var direct = AbLegacyHostAddress.TryParse("ab://10.0.0.1/"); + direct.ShouldNotBeNull(); + direct.IsDhPlusBridge.ShouldBeFalse(); + direct.BackplaneSlot.ShouldBeNull(); + direct.DhPlusPort.ShouldBeNull(); + direct.DhPlusStation.ShouldBeNull(); + + // Standard ControlLogix backplane bridge — only two segments, not four + var twoHop = AbLegacyHostAddress.TryParse("ab://10.0.0.1/1,0"); + twoHop.ShouldNotBeNull(); + twoHop.IsDhPlusBridge.ShouldBeFalse(); + twoHop.BackplaneSlot.ShouldBeNull(); + } + + [Theory] + [InlineData(AbLegacyPlcFamily.Slc500)] + [InlineData(AbLegacyPlcFamily.MicroLogix)] + [InlineData(AbLegacyPlcFamily.LogixPccc)] + public async Task DhPlusBridge_with_non_plc5_family_throws(AbLegacyPlcFamily nonPlc5) + { + var options = new AbLegacyDriverOptions + { + Devices = new[] + { + new AbLegacyDeviceOptions( + HostAddress: "ab://10.0.0.1/1,3,2,07", + PlcFamily: nonPlc5), + }, + }; + var driver = new AbLegacyDriver(options, "drv-test", new FakeAbLegacyTagFactory()); + var ex = await Should.ThrowAsync(async () => + await driver.InitializeAsync("{}", CancellationToken.None)); + ex.Message.ShouldContain("DHRIO"); + ex.Message.ShouldContain("PLC-5-only"); + } + + [Fact] + public async Task DhPlusBridge_with_plc5_family_initialises_cleanly() + { + var options = new AbLegacyDriverOptions + { + Devices = new[] + { + new AbLegacyDeviceOptions( + HostAddress: "ab://10.0.0.1/1,3,2,07", + PlcFamily: AbLegacyPlcFamily.Plc5), + }, + // Probe disabled so InitializeAsync doesn't try to spin a probe loop against a + // non-existent gateway; the only thing under test is the family validation. + Probe = new AbLegacyProbeOptions { Enabled = false }, + }; + var driver = new AbLegacyDriver(options, "drv-test", new FakeAbLegacyTagFactory()); + await driver.InitializeAsync("{}", CancellationToken.None); + } +}