@@ -1,3 +1,5 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -41,21 +43,38 @@ public sealed record AbLegacyAddress(
|
|||||||
return wordPart;
|
return wordPart;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AbLegacyAddress? TryParse(string? value)
|
public static AbLegacyAddress? TryParse(string? value) => TryParse(value, family: null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Family-aware parser. PLC-5 (RSLogix 5) displays the word + bit indices on
|
||||||
|
/// <c>I:</c>/<c>O:</c> file references as octal — <c>I:001/17</c> is rack 1, bit 15.
|
||||||
|
/// Pass the device's family so the parser can interpret those digits as octal when the
|
||||||
|
/// family's <see cref="AbLegacyPlcFamilyProfile.OctalIoAddressing"/> is true. The parsed
|
||||||
|
/// record stores decimal values; <see cref="ToLibplctagName"/> emits decimal too, which
|
||||||
|
/// is what libplctag's PCCC layer expects.
|
||||||
|
/// </summary>
|
||||||
|
public static AbLegacyAddress? TryParse(string? value, AbLegacyPlcFamily? family)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||||
var src = value.Trim();
|
var src = value.Trim();
|
||||||
|
|
||||||
// BitIndex: trailing /N
|
var profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value);
|
||||||
int? bitIndex = null;
|
|
||||||
|
// BitIndex: trailing /N. Defer numeric parsing until the file letter is known — PLC-5
|
||||||
|
// I:/O: bit indices are octal in RSLogix 5, everything else is decimal.
|
||||||
|
string? bitText = null;
|
||||||
var slashIdx = src.IndexOf('/');
|
var slashIdx = src.IndexOf('/');
|
||||||
if (slashIdx >= 0)
|
if (slashIdx >= 0)
|
||||||
{
|
{
|
||||||
if (!int.TryParse(src[(slashIdx + 1)..], out var bit) || bit < 0 || bit > 31) return null;
|
bitText = src[(slashIdx + 1)..];
|
||||||
bitIndex = bit;
|
|
||||||
src = src[..slashIdx];
|
src = src[..slashIdx];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return ParseTail(src, bitText, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AbLegacyAddress? ParseTail(string src, string? bitText, AbLegacyPlcFamilyProfile? profile)
|
||||||
|
{
|
||||||
// SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
|
// SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
|
||||||
string? subElement = null;
|
string? subElement = null;
|
||||||
var dotIdx = src.LastIndexOf('.');
|
var dotIdx = src.LastIndexOf('.');
|
||||||
@@ -73,7 +92,6 @@ public sealed record AbLegacyAddress(
|
|||||||
if (colonIdx <= 0) return null;
|
if (colonIdx <= 0) return null;
|
||||||
var filePart = src[..colonIdx];
|
var filePart = src[..colonIdx];
|
||||||
var wordPart = src[(colonIdx + 1)..];
|
var wordPart = src[(colonIdx + 1)..];
|
||||||
if (!int.TryParse(wordPart, out var word) || word < 0) return null;
|
|
||||||
|
|
||||||
// File letter + optional file number (single letter for I/O/S, letter+number otherwise).
|
// File letter + optional file number (single letter for I/O/S, letter+number otherwise).
|
||||||
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
|
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
|
||||||
@@ -91,9 +109,43 @@ public sealed record AbLegacyAddress(
|
|||||||
// Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families.
|
// Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families.
|
||||||
if (!IsKnownFileLetter(letter)) return null;
|
if (!IsKnownFileLetter(letter)) return null;
|
||||||
|
|
||||||
|
var octalForIo = profile?.OctalIoAddressing == true && (letter == "I" || letter == "O");
|
||||||
|
|
||||||
|
if (!TryParseIndex(wordPart, octalForIo, out var word) || word < 0) return null;
|
||||||
|
|
||||||
|
int? bitIndex = null;
|
||||||
|
if (bitText is not null)
|
||||||
|
{
|
||||||
|
if (!TryParseIndex(bitText, octalForIo, out var bit) || bit < 0 || bit > 31) return null;
|
||||||
|
bitIndex = bit;
|
||||||
|
}
|
||||||
|
|
||||||
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement);
|
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryParseIndex(string text, bool octal, out int value)
|
||||||
|
{
|
||||||
|
if (octal)
|
||||||
|
{
|
||||||
|
// Octal accepts only digits 0-7. Reject 8/9 explicitly.
|
||||||
|
if (text.Length == 0) { value = 0; return false; }
|
||||||
|
var start = 0;
|
||||||
|
var sign = 1;
|
||||||
|
if (text[0] == '-') { sign = -1; start = 1; }
|
||||||
|
if (start >= text.Length) { value = 0; return false; }
|
||||||
|
var acc = 0;
|
||||||
|
for (var i = start; i < text.Length; i++)
|
||||||
|
{
|
||||||
|
var c = text[i];
|
||||||
|
if (c < '0' || c > '7') { value = 0; return false; }
|
||||||
|
acc = (acc * 8) + (c - '0');
|
||||||
|
}
|
||||||
|
value = sign * acc;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return int.TryParse(text, out value);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsKnownFileLetter(string letter) => letter switch
|
private static bool IsKnownFileLetter(string letter) => letter switch
|
||||||
{
|
{
|
||||||
"N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true,
|
"N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true,
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||||
var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
|
var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
|
||||||
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
|
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
|
||||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
@@ -186,7 +186,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||||
|
|
||||||
// PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel
|
// PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel
|
||||||
// parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises
|
// parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises
|
||||||
@@ -413,7 +413,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
{
|
{
|
||||||
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
||||||
|
|
||||||
var parsed = AbLegacyAddress.TryParse(def.Address)
|
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public sealed record AbLegacyDeviceOptions(
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
|
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
|
||||||
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse"/>.
|
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse(string?)"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record AbLegacyTagDefinition(
|
public sealed record AbLegacyTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ public sealed record AbLegacyPlcFamilyProfile(
|
|||||||
string DefaultCipPath,
|
string DefaultCipPath,
|
||||||
int MaxTagBytes,
|
int MaxTagBytes,
|
||||||
bool SupportsStringFile,
|
bool SupportsStringFile,
|
||||||
bool SupportsLongFile)
|
bool SupportsLongFile,
|
||||||
|
bool OctalIoAddressing)
|
||||||
{
|
{
|
||||||
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
|
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
|
||||||
{
|
{
|
||||||
@@ -25,21 +26,24 @@ public sealed record AbLegacyPlcFamilyProfile(
|
|||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
|
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
|
||||||
SupportsStringFile: true, // ST file available SLC 5/04+
|
SupportsStringFile: true, // ST file available SLC 5/04+
|
||||||
SupportsLongFile: true); // L file available SLC 5/05+
|
SupportsLongFile: true, // L file available SLC 5/05+
|
||||||
|
OctalIoAddressing: false); // SLC500 I:/O: indices are decimal in RSLogix 500
|
||||||
|
|
||||||
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
|
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
|
||||||
LibplctagPlcAttribute: "micrologix",
|
LibplctagPlcAttribute: "micrologix",
|
||||||
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
|
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
|
||||||
MaxTagBytes: 232,
|
MaxTagBytes: 232,
|
||||||
SupportsStringFile: true,
|
SupportsStringFile: true,
|
||||||
SupportsLongFile: false); // ML 1100/1200/1400 don't ship L files
|
SupportsLongFile: false, // ML 1100/1200/1400 don't ship L files
|
||||||
|
OctalIoAddressing: false); // MicroLogix follows SLC-style decimal I/O addressing
|
||||||
|
|
||||||
public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
|
public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
|
||||||
LibplctagPlcAttribute: "plc5",
|
LibplctagPlcAttribute: "plc5",
|
||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
|
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
|
||||||
SupportsStringFile: true,
|
SupportsStringFile: true,
|
||||||
SupportsLongFile: false); // PLC-5 predates L files
|
SupportsLongFile: false, // PLC-5 predates L files
|
||||||
|
OctalIoAddressing: true); // RSLogix 5 displays I:/O: word + bit indices as octal
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
||||||
@@ -51,7 +55,8 @@ public sealed record AbLegacyPlcFamilyProfile(
|
|||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
MaxTagBytes: 240,
|
MaxTagBytes: 240,
|
||||||
SupportsStringFile: true,
|
SupportsStringFile: true,
|
||||||
SupportsLongFile: true);
|
SupportsLongFile: true,
|
||||||
|
OctalIoAddressing: false); // Logix natively uses decimal arrays even via the PCCC bridge
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Which PCCC PLC family the device is.</summary>
|
/// <summary>Which PCCC PLC family the device is.</summary>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||||
|
|
||||||
@@ -65,4 +66,77 @@ public sealed class AbLegacyAddressTests
|
|||||||
a.ShouldNotBeNull();
|
a.ShouldNotBeNull();
|
||||||
a.ToLibplctagName().ShouldBe(input);
|
a.ToLibplctagName().ShouldBe(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- PLC-5 octal I:/O: addressing (Issue #244) ----
|
||||||
|
//
|
||||||
|
// RSLogix 5 displays I:/O: word + bit indices as octal. `I:001/17` means rack 1, bit 15
|
||||||
|
// (octal 17). Other PCCC families (SLC500, MicroLogix, LogixPccc) keep decimal indices.
|
||||||
|
// Non-I/O file letters are always decimal regardless of family.
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("I:001/17", 1, 15)] // octal 17 → bit 15
|
||||||
|
[InlineData("I:0/0", 0, 0)] // boundary: octal 0
|
||||||
|
[InlineData("O:1/2", 1, 2)] // octal 1, 2 happen to match decimal
|
||||||
|
[InlineData("I:010/10", 8, 8)] // octal 10 → 8 (both word + bit)
|
||||||
|
[InlineData("I:007/7", 7, 7)] // boundary: largest single octal digit
|
||||||
|
public void TryParse_Plc5_parses_io_indices_as_octal(string input, int expectedWord, int expectedBit)
|
||||||
|
{
|
||||||
|
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5);
|
||||||
|
a.ShouldNotBeNull();
|
||||||
|
a.WordNumber.ShouldBe(expectedWord);
|
||||||
|
a.BitIndex.ShouldBe(expectedBit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("I:8/0")] // word digit 8 illegal in octal
|
||||||
|
[InlineData("I:0/9")] // bit digit 9 illegal in octal
|
||||||
|
[InlineData("O:128/0")] // contains digit 8
|
||||||
|
[InlineData("I:0/18")] // bit field octal-illegal because of '8'
|
||||||
|
public void TryParse_Plc5_rejects_octal_invalid_io_digits(string input)
|
||||||
|
{
|
||||||
|
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// Non-I/O files stay decimal even on PLC-5 (e.g. N7:8 is integer 7, word 8).
|
||||||
|
[InlineData("N7:8", 7, 8)]
|
||||||
|
[InlineData("F8:9", 8, 9)]
|
||||||
|
public void TryParse_Plc5_keeps_non_io_indices_decimal(string input, int? expectedFile, int expectedWord)
|
||||||
|
{
|
||||||
|
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5);
|
||||||
|
a.ShouldNotBeNull();
|
||||||
|
a.FileNumber.ShouldBe(expectedFile);
|
||||||
|
a.WordNumber.ShouldBe(expectedWord);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_Slc500_keeps_io_indices_decimal_back_compat()
|
||||||
|
{
|
||||||
|
// SLC500 has OctalIoAddressing=false — the digits are decimal as before.
|
||||||
|
var a = AbLegacyAddress.TryParse("I:10/15", AbLegacyPlcFamily.Slc500);
|
||||||
|
a.ShouldNotBeNull();
|
||||||
|
a.WordNumber.ShouldBe(10);
|
||||||
|
a.BitIndex.ShouldBe(15);
|
||||||
|
|
||||||
|
// Decimal '8' that PLC-5 would reject is fine on SLC500.
|
||||||
|
var b = AbLegacyAddress.TryParse("I:8/0", AbLegacyPlcFamily.Slc500);
|
||||||
|
b.ShouldNotBeNull();
|
||||||
|
b.WordNumber.ShouldBe(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_MicroLogix_and_LogixPccc_keep_io_indices_decimal()
|
||||||
|
{
|
||||||
|
AbLegacyAddress.TryParse("I:9/0", AbLegacyPlcFamily.MicroLogix).ShouldNotBeNull();
|
||||||
|
AbLegacyAddress.TryParse("I:9/0", AbLegacyPlcFamily.LogixPccc).ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Plc5Profile_advertises_octal_io_addressing()
|
||||||
|
{
|
||||||
|
AbLegacyPlcFamilyProfile.Plc5.OctalIoAddressing.ShouldBeTrue();
|
||||||
|
AbLegacyPlcFamilyProfile.Slc500.OctalIoAddressing.ShouldBeFalse();
|
||||||
|
AbLegacyPlcFamilyProfile.MicroLogix.OctalIoAddressing.ShouldBeFalse();
|
||||||
|
AbLegacyPlcFamilyProfile.LogixPccc.OctalIoAddressing.ShouldBeFalse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user