Merge pull request '[ablegacy] AbLegacy — DH+ via 1756-DHRIO bridging' (#400) from auto/ablegacy/13 into auto/driver-gaps

This commit was merged in pull request #400.
This commit is contained in:
2026-04-26 08:59:02 -04:00
8 changed files with 451 additions and 5 deletions

View File

@@ -32,6 +32,27 @@ Family ↔ CIP-path cheat sheet:
with no backplane with no backplane
- **LogixPccc** — `1,0` (Logix controller accessed via the PCCC compatibility - **LogixPccc** — `1,0` (Logix controller accessed via the PCCC compatibility
layer; rare) layer; rare)
- **PLC-5 via 1756-DHRIO bridge** — `1,<slot>,2,<station-octal>` (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,<slot>,2,<station>`: 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) ### Per-device timeout / retry tuning (#252, PR 9)

View File

@@ -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 `<N>` (the slot the DHRIO module lives in).
2. **DH+** — port `2`, station `<S>` (the DH+ node address of the target PLC-5,
in **octal**).
Resulting CIP path: `1,<N>,2,<S>`.
> 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 `<N>` in `1,<N>,2,<S>`).
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://<gateway>/1,<slot>,2,<station-octal> -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.

View File

@@ -132,6 +132,17 @@ driver-side correctness depends on libplctag being correct.
`IPerCallHostResolver` contract is verified; real PCCC wire routing across `IPerCallHostResolver` contract is verified; real PCCC wire routing across
multiple gateways is not. 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 ### 4. Alarms / history
PCCC has no alarm object + no history object. Driver doesn't implement PCCC has no alarm object + no history object. Driver doesn't implement

View File

@@ -54,6 +54,16 @@
Failure threshold the server is configured with (default 3). The Failure threshold the server is configured with (default 3). The
demote assertion writes/reads N+1 times against the killed simulator demote assertion writes/reads N+1 times against the killed simulator
to guarantee the threshold trips even if some reads beat the kill. 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://<host>/1,<slot>,2,<station-octal>` 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( param(
@@ -64,7 +74,11 @@ param(
[Parameter(Mandatory)] [string]$BridgeNodeId, [Parameter(Mandatory)] [string]$BridgeNodeId,
[string]$DiagnosticsRequestCountNodeId, [string]$DiagnosticsRequestCountNodeId,
[string]$DiagnosticsDemoteCountNodeId, [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" $ErrorActionPreference = "Stop"

View File

@@ -163,6 +163,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var addr = AbLegacyHostAddress.TryParse(device.HostAddress) var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
$"AbLegacy device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'."); $"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); var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily);
_devices[device.HostAddress] = new DeviceState(addr, device, profile); _devices[device.HostAddress] = new DeviceState(addr, device, profile);
// PR ablegacy-10 / #253 — pre-allocate the diagnostic-counter slot so the // PR ablegacy-10 / #253 — pre-allocate the diagnostic-counter slot so the

View File

@@ -7,14 +7,38 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// a direct-wired SLC 500 uses an empty path). /// a direct-wired SLC 500 uses an empty path).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Parser duplicated from AbCipHostAddress rather than shared because the two drivers ship /// <para>Parser duplicated from AbCipHostAddress rather than shared because the two drivers
/// independently + a shared helper would force a reference between them. If a third AB /// ship independently + a shared helper would force a reference between them. If a third AB
/// driver appears, extract into Core.Abstractions. /// driver appears, extract into Core.Abstractions.</para>
/// <para>PR ablegacy-13 / #256 — the optional <see cref="BackplaneSlot"/>,
/// <see cref="DhPlusPort"/> and <see cref="DhPlusStation"/> fields are populated when the
/// CIP path matches the canonical 1756-DHRIO bridge form <c>1,&lt;slot&gt;,2,&lt;station&gt;</c>:
/// 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.</para>
/// </remarks> /// </remarks>
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; public const int DefaultEipPort = 44818;
/// <summary>
/// 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.
/// </summary>
public const int MaxBackplaneSlot = 16;
/// <summary>True iff the CIP path was the canonical 1756-DHRIO DH+ bridge form.</summary>
public bool IsDhPlusBridge => DhPlusStation is not null;
public override string ToString() => Port == DefaultEipPort public override string ToString() => Port == DefaultEipPort
? $"ab://{Gateway}/{CipPath}" ? $"ab://{Gateway}/{CipPath}"
: $"ab://{Gateway}:{Port}/{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; 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); return new AbLegacyHostAddress(gateway, port, cipPath);
} }
/// <summary>
/// Returns <c>true</c> iff <paramref name="cipPath"/> is exactly the four-segment DHRIO
/// bridge form <c>1,&lt;slot&gt;,2,&lt;station&gt;</c>. Returns <c>false</c> 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.
/// </summary>
/// <remarks>
/// 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 <c>AbLegacyAddress</c>'s PLC-5
/// I/O parser — a leading <c>8</c> or <c>9</c> in any digit is rejected.
/// </remarks>
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;
}
/// <summary>
/// Parse a DH+ station number written in octal (0..77 octal = 0..63 decimal). Mirrors
/// the octal-rule in <c>AbLegacyAddress.TryParseIndex</c> — digits 0..7 only, no sign,
/// no prefix. Returns <c>false</c> for empty input, illegal digits (8/9), or non-digit
/// characters.
/// </summary>
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;
}
} }

View File

@@ -52,6 +52,14 @@ public sealed record AbLegacyPlcFamilyProfile(
SupportsPlsFile: false, SupportsPlsFile: false,
SupportsBlockTransferFile: false); SupportsBlockTransferFile: false);
/// <remarks>
/// PR ablegacy-13 / #256 — PLC-5 is the only family that supports the 1756-DHRIO DH+
/// bridging form (CIP path <c>1,&lt;slot&gt;,2,&lt;station-octal&gt;</c>). 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 <c>AbLegacyDriver.InitializeAsync</c> time. See
/// <c>docs/drivers/AbLegacy-DH-Bridging.md</c> for the full DH+ syntax + smoke procedure.
/// </remarks>
public static readonly AbLegacyPlcFamilyProfile Plc5 = new( public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
LibplctagPlcAttribute: "plc5", LibplctagPlcAttribute: "plc5",
DefaultCipPath: "1,0", DefaultCipPath: "1,0",

View File

@@ -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;
/// <summary>
/// PR ablegacy-13 / #256 — coverage for the 1756-DHRIO DH+ bridge form on
/// <see cref="AbLegacyHostAddress"/>: octal-station validation, slot bounds, port shape,
/// and the PLC-5-only family guard at <c>AbLegacyDriver.InitializeAsync</c>.
/// </summary>
[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<InvalidOperationException>(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);
}
}