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

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