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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
141
docs/drivers/AbLegacy-DH-Bridging.md
Normal file
141
docs/drivers/AbLegacy-DH-Bridging.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,<slot>,2,<station></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,<slot>,2,<station></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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,<slot>,2,<station-octal></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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user