Auto: ablegacy-13 — DH+ via 1756-DHRIO bridging validation

Closes #256
This commit is contained in:
Joseph Doherty
2026-04-26 08:56:23 -04:00
parent 08a4db2952
commit 399257377b
8 changed files with 451 additions and 5 deletions

View File

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

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

View File

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

View File

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

View File

@@ -7,14 +7,38 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// a direct-wired SLC 500 uses an empty path).
/// </summary>
/// <remarks>
/// 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.
/// <para>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.</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>
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;
/// <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
? $"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);
}
/// <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,
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(
LibplctagPlcAttribute: "plc5",
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);
}
}