Merge AbCip fixes found against real CompactLogix presses
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using libplctag;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
@@ -798,6 +799,21 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
var bitIndex = tagPath?.BitIndex;
|
||||
|
||||
// libplctag never checks the CIP type the controller returned; it decodes the bytes as
|
||||
// the authored type. A scalar whose buffer is not that type's width is mis-typed config
|
||||
// (seen live: a REAL authored as INT read Good -17346), so refuse it rather than publish
|
||||
// garbage as Good. Same-width mismatches (DINT vs REAL) cannot be seen this way.
|
||||
if (!IsArrayTag(def) && bitIndex is null
|
||||
&& ExpectedScalarWidth(def.DataType) is { } width
|
||||
&& runtime.GetSize() is var size && size >= 0 && size != width)
|
||||
{
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
|
||||
_logger.LogWarning(
|
||||
"AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType",
|
||||
reference, def.DeviceHostAddress, size, def.DataType, width);
|
||||
return ReadOutcome.Degraded($"type mismatch reading {reference}: {size} bytes for {def.DataType}");
|
||||
}
|
||||
// Review I-1 — an array tag (the EXPLICIT IsArray flag) decodes the whole buffer into an
|
||||
// element-typed CLR array (int[]/float[]/bool[]/string[]…), INCLUDING a 1-element array
|
||||
// (ElementCount 1). Scalar tags keep the single-value path.
|
||||
@@ -818,10 +834,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transport exception — evict so the next read creates a fresh handle.
|
||||
// Transport exception — evict so the next read creates a fresh handle. A libplctag
|
||||
// exception carries its Status, so a tag the controller does not have reads
|
||||
// BadNodeIdUnknown and a decode past the buffer BadOutOfRange, not a comms error.
|
||||
EvictRuntime(device, def.Name);
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.BadCommunicationError, null, now);
|
||||
AbCipStatusMapper.MapException(ex), null, now);
|
||||
_logger.LogWarning(ex,
|
||||
"AbCip read transport exception for tag {Tag} on device {Device}",
|
||||
reference, def.DeviceHostAddress);
|
||||
@@ -891,7 +909,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
catch (Exception ex)
|
||||
{
|
||||
EvictRuntime(device, parent.Name);
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.MapException(ex));
|
||||
_logger.LogWarning(ex,
|
||||
"AbCip whole-UDT read transport exception for parent {Parent} on device {Device}",
|
||||
group.ParentName, parent.DeviceHostAddress);
|
||||
@@ -1158,10 +1176,20 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
runtime.Dispose();
|
||||
device.Backoff.RecordFailure(DateTime.UtcNow); // 05/STAB-8: open the backoff window
|
||||
// 05/STAB-8: a failed create opens the backoff window — but only when the device did
|
||||
// not answer. "Tag not found" means the controller replied over a working session; it
|
||||
// is a config error on this one tag, and counting it as a failed Forward-Open throttled
|
||||
// every sibling tag on the device (seen live: one typo blacked out the whole device).
|
||||
if (ex is LibPlcTagException lpe
|
||||
&& AbCipStatusMapper.TryGetStatus(lpe, out var status)
|
||||
&& status != Status.Ok
|
||||
&& AbCipDriverProbe.IsReachableStatus(status))
|
||||
device.Backoff.RecordSuccess();
|
||||
else
|
||||
device.Backoff.RecordFailure(DateTime.UtcNow);
|
||||
throw;
|
||||
}
|
||||
device.Backoff.RecordSuccess(); // 05/STAB-8: Forward-Open succeeded — reset the window
|
||||
@@ -1285,6 +1313,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// bogus single Structure → String placeholder Variable. When the shape can't be
|
||||
// resolved (or yields no emittable leaf) we fall through to the legacy single
|
||||
// Variable so nothing regresses.
|
||||
// Review I-1 — a discovered array of length 1 is still an array; honour the
|
||||
// explicit IsArray flag (legacy ElementCount > 1 still surfaces as an array).
|
||||
var discoveredIsArray = discovered.IsArray || discovered.ElementCount > 1;
|
||||
var discoveredType = discovered.DataType;
|
||||
|
||||
if (discovered.DataType == AbCipDataType.Structure)
|
||||
{
|
||||
// Pass the discovered tag's CIP template instance id (surfaced by the Symbol
|
||||
@@ -1294,7 +1327,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var shape = await ResolveDiscoveredUdtShapeAsync(
|
||||
device.HostAddress, discovered.Name, discovered.TemplateInstanceId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (shape is not null)
|
||||
if (shape is { IsStringFamily: true })
|
||||
{
|
||||
// A controller reports every STRING (built-in or a user string type such as
|
||||
// STR0016) as a LEN + DATA struct. It is one String value — the read path reads
|
||||
// it as such — so it becomes one String Variable, never a LEN/DATA folder.
|
||||
discoveredType = AbCipDataType.String;
|
||||
}
|
||||
else if (shape is not null && !discoveredIsArray)
|
||||
{
|
||||
// Materialise the member sub-folder LAZILY — only once a leaf is actually
|
||||
// about to be emitted into it. A shape that yields no usable atomic leaf
|
||||
@@ -1313,12 +1353,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
|
||||
if (!emitted.Add(fullName)) continue;
|
||||
// Review I-1 — a discovered array of length 1 is still an array; honour the
|
||||
// explicit IsArray flag (legacy ElementCount > 1 still surfaces as an array).
|
||||
var discoveredIsArray = discovered.IsArray || discovered.ElementCount > 1;
|
||||
discoveredFolder.Variable(fullName, discovered.Name, new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: discovered.DataType.ToDriverDataType(),
|
||||
DriverDataType: discoveredType.ToDriverDataType(),
|
||||
IsArray: discoveredIsArray,
|
||||
ArrayDim: discoveredIsArray ? (uint)Math.Max(1, discovered.ElementCount) : null,
|
||||
SecurityClass: discovered.ReadOnly
|
||||
@@ -1327,12 +1364,39 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false,
|
||||
NativeDataType: discovered.DataType.ToString()));
|
||||
NativeDataType: discoveredType.ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> for a UDT member the controller generates rather than the user declares: the
|
||||
/// hidden SINT/DINT hosts Logix packs BOOL members into (<c>ZZZZZZZZZZAlarms0</c>) and AOI
|
||||
/// backing members (<c>__BitHost00</c>). A user-declared Logix name can never begin with two
|
||||
/// underscores, so neither rule can hide a real member.
|
||||
/// </summary>
|
||||
/// <param name="memberName">The member name from the template.</param>
|
||||
/// <returns><c>true</c> when the member is controller-generated.</returns>
|
||||
internal static bool IsHiddenUdtMember(string memberName) =>
|
||||
memberName.StartsWith("ZZZZZZZZZZ", StringComparison.Ordinal)
|
||||
|| memberName.StartsWith("__", StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Wire width of a scalar atomic Logix type, or <c>null</c> where the read buffer's size is not
|
||||
/// a reliable type check (BOOL, STRING, DT, structures).
|
||||
/// </summary>
|
||||
/// <param name="type">The authored type.</param>
|
||||
/// <returns>The width in bytes, or <c>null</c>.</returns>
|
||||
internal static int? ExpectedScalarWidth(AbCipDataType type) => type switch
|
||||
{
|
||||
AbCipDataType.SInt or AbCipDataType.USInt => 1,
|
||||
AbCipDataType.Int or AbCipDataType.UInt => 2,
|
||||
AbCipDataType.DInt or AbCipDataType.UDInt or AbCipDataType.Real => 4,
|
||||
AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.LReal => 8,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Recursively fan a controller-discovered UDT shape out into Variable nodes — one per
|
||||
/// ATOMIC leaf — under the folder produced by <paramref name="folder"/>. Mirrors the
|
||||
@@ -1381,37 +1445,50 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var anyEmitted = false;
|
||||
foreach (var member in shape.Members)
|
||||
{
|
||||
if (IsHiddenUdtMember(member.Name)) continue;
|
||||
var memberFullName = $"{parentFullName}.{member.Name}";
|
||||
var leafType = member.DataType;
|
||||
|
||||
if (member.DataType == AbCipDataType.Structure)
|
||||
{
|
||||
// Nested struct: recurse into its sub-shape if it resolves, the depth cap allows it,
|
||||
// and we're not re-entering a struct already on this path (cycle guard). The nested
|
||||
// members live a level deeper, so the cap is checked against depth + 1.
|
||||
if (depth + 1 > MaxUdtDepth) continue;
|
||||
if (!visited.Add(member.Name)) continue; // cyclic UDT reference — stop here
|
||||
// The decoded parent shape carries the nested UDT's template instance id (low 12 bits of the
|
||||
// member info, captured by CipTemplateObjectDecoder) — thread it so the nested shape is fetched
|
||||
// off the controller via FetchUdtShapeAsync, making nested-struct members addressable in
|
||||
// production (name-keyed seeded shapes still win first, for the test seam).
|
||||
// Nested struct: resolve its sub-shape first. The decoded parent shape carries the nested
|
||||
// UDT's template instance id (low 12 bits of the member type word, captured by
|
||||
// CipTemplateObjectDecoder) — thread it so the nested shape is fetched off the controller
|
||||
// via FetchUdtShapeAsync (name-keyed seeded shapes still win first, for the test seam).
|
||||
var nested = await ResolveDiscoveredUdtShapeAsync(
|
||||
deviceHostAddress, member.Name, templateInstanceId: member.NestedTemplateId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (nested is not null)
|
||||
|
||||
if (nested is { IsStringFamily: true })
|
||||
{
|
||||
// Materialise the nested sub-folder lazily under the parent folder (itself lazy),
|
||||
// so a nested struct that yields no leaf leaves no empty sub-folder behind.
|
||||
var parentFolder = folder;
|
||||
var nestedFolder = new Lazy<IAddressSpaceBuilder>(
|
||||
() => parentFolder.Value.Folder(memberFullName, member.Name));
|
||||
var nestedEmitted = await FanOutDiscoveredUdtMembersAsync(
|
||||
nestedFolder, deviceHostAddress, memberFullName, nested, parentReadOnly,
|
||||
depth + 1, emitted, visited, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
anyEmitted |= nestedEmitted;
|
||||
// A nested STRING (or user string type) member is one String leaf, not LEN + DATA.
|
||||
leafType = AbCipDataType.String;
|
||||
}
|
||||
else
|
||||
{
|
||||
// An array of a UDT has no element-free member path (Parent.Arr.X names no element),
|
||||
// so it is not recursed into. Otherwise recurse if the depth cap allows it and we're
|
||||
// not re-entering a struct already on this path (cycle guard). The nested members
|
||||
// live a level deeper, so the cap is checked against depth + 1.
|
||||
if (member.ArrayLength > 1) continue;
|
||||
if (depth + 1 > MaxUdtDepth) continue;
|
||||
if (!visited.Add(member.Name)) continue; // cyclic UDT reference — stop here
|
||||
if (nested is not null)
|
||||
{
|
||||
// Materialise the nested sub-folder lazily under the parent folder (itself lazy),
|
||||
// so a nested struct that yields no leaf leaves no empty sub-folder behind.
|
||||
var parentFolder = folder;
|
||||
var nestedFolder = new Lazy<IAddressSpaceBuilder>(
|
||||
() => parentFolder.Value.Folder(memberFullName, member.Name));
|
||||
var nestedEmitted = await FanOutDiscoveredUdtMembersAsync(
|
||||
nestedFolder, deviceHostAddress, memberFullName, nested, parentReadOnly,
|
||||
depth + 1, emitted, visited, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
anyEmitted |= nestedEmitted;
|
||||
}
|
||||
visited.Remove(member.Name);
|
||||
continue;
|
||||
}
|
||||
visited.Remove(member.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Atomic leaf — emit one Variable. Dedup by full-name within the Discovered folder.
|
||||
@@ -1420,7 +1497,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var memberIsArray = member.ArrayLength > 1;
|
||||
folder.Value.Variable(member.Name, member.Name, new DriverAttributeInfo(
|
||||
FullName: memberFullName,
|
||||
DriverDataType: member.DataType.ToDriverDataType(),
|
||||
DriverDataType: leafType.ToDriverDataType(),
|
||||
IsArray: memberIsArray,
|
||||
ArrayDim: memberIsArray ? (uint)Math.Max(1, member.ArrayLength) : null,
|
||||
// The Symbol Object carries no per-member ExternalAccess bits, so members inherit the
|
||||
@@ -1431,7 +1508,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false,
|
||||
NativeDataType: member.DataType.ToString()));
|
||||
NativeDataType: leafType.ToString()));
|
||||
anyEmitted = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
internal static bool IsReachableException(LibPlcTagException ex)
|
||||
{
|
||||
var msg = ex.Message ?? string.Empty;
|
||||
if (Enum.TryParse<Status>(msg.Trim(), ignoreCase: true, out var status) && Enum.IsDefined(status))
|
||||
if (AbCipStatusMapper.TryGetStatus(ex, out var status))
|
||||
return IsReachableStatus(status);
|
||||
|
||||
return msg.Contains("NOT_FOUND", StringComparison.OrdinalIgnoreCase)
|
||||
|
||||
@@ -76,6 +76,34 @@ public static class AbCipStatusMapper
|
||||
/// <returns>The mapped OPC UA StatusCode value.</returns>
|
||||
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
|
||||
|
||||
/// <summary>
|
||||
/// Recover the libplctag <see cref="Status"/> a <see cref="LibPlcTagException"/> was thrown on.
|
||||
/// The wrapper builds the exception from <c>Status.ToString()</c>, so the message is the enum
|
||||
/// name (<c>"ErrorNotFound"</c>).
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <param name="status">The recovered status.</param>
|
||||
/// <returns><c>true</c> when the message names a defined <see cref="Status"/>.</returns>
|
||||
public static bool TryGetStatus(LibPlcTagException ex, out Status status)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
return Enum.TryParse((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out status)
|
||||
&& Enum.IsDefined(status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status for an exception thrown while creating / reading a tag: a libplctag exception maps
|
||||
/// through its <see cref="Status"/> (so a tag the controller does not have reads
|
||||
/// <see cref="BadNodeIdUnknown"/>, not a comms error); anything else is
|
||||
/// <see cref="BadCommunicationError"/>.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <returns>The OPC UA status code.</returns>
|
||||
public static uint MapException(Exception ex) =>
|
||||
ex is LibPlcTagException lpe && TryGetStatus(lpe, out var status)
|
||||
? MapLibplctagStatus(status)
|
||||
: BadCommunicationError;
|
||||
|
||||
/// <summary>
|
||||
/// Map a libplctag.NET <see cref="Status"/> enum value to an OPC UA StatusCode. This is
|
||||
/// the strongly-typed core of the mapper; the <c>int</c> overload exists only for the
|
||||
|
||||
@@ -100,7 +100,10 @@ public sealed record AbCipTagPath(
|
||||
var bracketIdx = part.IndexOf('[');
|
||||
if (bracketIdx < 0)
|
||||
{
|
||||
if (!IsValidIdent(part)) return null;
|
||||
// The controller-scope base name of a module-defined I/O tag carries colons
|
||||
// (Local:1:I, ABB_OmniCore:I) — legal in the first segment only.
|
||||
var isModuleBase = segments.Count == 0 && programScope is null && IsValidModuleBase(part);
|
||||
if (!isModuleBase && !IsValidIdent(part)) return null;
|
||||
segments.Add(new AbCipTagPathSegment(part, []));
|
||||
continue;
|
||||
}
|
||||
@@ -122,6 +125,25 @@ public sealed record AbCipTagPath(
|
||||
return new AbCipTagPath(programScope, segments, bitIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> for a module-defined I/O tag base name: an identifier followed by one or more
|
||||
/// <c>:</c>-separated identifier or slot-number parts (<c>Local:1:I</c>, <c>ABB_OmniCore:I</c>).
|
||||
/// </summary>
|
||||
/// <param name="s">The candidate base name.</param>
|
||||
/// <returns><c>true</c> when <paramref name="s"/> is a module tag base name.</returns>
|
||||
private static bool IsValidModuleBase(string s)
|
||||
{
|
||||
var parts = s.Split(':');
|
||||
if (parts.Length < 2 || !IsValidIdent(parts[0])) return false;
|
||||
for (var i = 1; i < parts.Length; i++)
|
||||
{
|
||||
var p = parts[i];
|
||||
if (p.Length == 0) return false;
|
||||
if (!IsValidIdent(p) && !p.All(char.IsAsciiDigit)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsValidIdent(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return false;
|
||||
|
||||
@@ -57,7 +57,19 @@ public sealed class AbCipTemplateCache
|
||||
public sealed record AbCipUdtShape(
|
||||
string TypeName,
|
||||
int TotalSize,
|
||||
IReadOnlyList<AbCipUdtMember> Members);
|
||||
IReadOnlyList<AbCipUdtMember> Members)
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>true</c> when the shape is a Logix string-family type — the built-in <c>STRING</c> or
|
||||
/// a user string type such as <c>STR0016</c>: exactly a <c>LEN</c> DINT at offset 0 and a
|
||||
/// <c>DATA</c> SINT array at offset 4. A controller reports every string tag as a struct of
|
||||
/// this shape, but it is read and presented as one String value, never as LEN + DATA leaves.
|
||||
/// </summary>
|
||||
public bool IsStringFamily =>
|
||||
Members.Count == 2
|
||||
&& Members[0] is { Name: "LEN", DataType: AbCipDataType.DInt, Offset: 0 }
|
||||
&& Members[1] is { Name: "DATA", DataType: AbCipDataType.SInt, Offset: 4, ArrayLength: > 1 };
|
||||
}
|
||||
|
||||
/// <summary>One member of a Logix UDT.</summary>
|
||||
/// <param name="Name">Member name as reported by the Template Object.</param>
|
||||
|
||||
@@ -42,6 +42,10 @@ public static class CipSymbolObjectDecoder
|
||||
private const ushort SymbolTypeSystemFlag = 0x1000;
|
||||
private const ushort SymbolTypeStructFlag = 0x8000;
|
||||
private const ushort SymbolTypeTypeCodeMask = 0x0FFF;
|
||||
private const int SymbolTypeDimsShift = 13;
|
||||
private const int SymbolTypeDimsMask = 0x3;
|
||||
|
||||
private const string ProgramPrefix = "Program:";
|
||||
|
||||
/// <summary>
|
||||
/// Decode the raw <c>@tags</c> blob into an enumerable sequence. Malformed entries at
|
||||
@@ -49,22 +53,44 @@ public static class CipSymbolObjectDecoder
|
||||
/// cleanly before the corruption.
|
||||
/// </summary>
|
||||
/// <param name="buffer">The raw byte buffer from the Symbol Object response.</param>
|
||||
/// <param name="programScope">The program this listing belongs to when it is a
|
||||
/// <c>Program:<name>.@tags</c> read — whose entries carry bare names — or <c>null</c>
|
||||
/// for the controller-scope <c>@tags</c> listing.</param>
|
||||
/// <returns>An enumerable sequence of discovered CIP tags.</returns>
|
||||
public static IEnumerable<AbCipDiscoveredTag> Decode(byte[] buffer)
|
||||
public static IEnumerable<AbCipDiscoveredTag> Decode(byte[] buffer, string? programScope = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(buffer);
|
||||
return DecodeImpl(buffer);
|
||||
return DecodeImpl(buffer, programScope);
|
||||
}
|
||||
|
||||
private static IEnumerable<AbCipDiscoveredTag> DecodeImpl(byte[] buffer)
|
||||
/// <summary>
|
||||
/// The program name when <paramref name="tag"/> is a controller-scope program entry
|
||||
/// (<c>Program:UN01_Press</c>, a system symbol) — the name to read <c>Program:<name>.@tags</c>
|
||||
/// for, since a controller lists program-scope tags only under their own program. <c>null</c>
|
||||
/// for anything else.
|
||||
/// </summary>
|
||||
/// <param name="tag">A decoded controller-scope symbol.</param>
|
||||
/// <returns>The program name, or <c>null</c>.</returns>
|
||||
public static string? ProgramNameOf(AbCipDiscoveredTag tag)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
if (!tag.IsSystemTag || tag.ProgramScope is not null) return null;
|
||||
if (!tag.Name.StartsWith(ProgramPrefix, StringComparison.Ordinal)) return null;
|
||||
var name = tag.Name[ProgramPrefix.Length..];
|
||||
return name.Length == 0 || name.Contains('.') ? null : name;
|
||||
}
|
||||
|
||||
private static IEnumerable<AbCipDiscoveredTag> DecodeImpl(byte[] buffer, string? listingScope)
|
||||
{
|
||||
var pos = 0;
|
||||
while (pos + FixedHeaderSize <= buffer.Length)
|
||||
{
|
||||
var instanceId = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos));
|
||||
var symbolType = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 4));
|
||||
// element_length at pos+6 (u16) — useful for array sizing but not surfaced here
|
||||
// array_dims at pos+8, pos+12, pos+16 — same (scalar-tag case has all zeros)
|
||||
// element_length at pos+6 (u16) — not surfaced.
|
||||
var dim0 = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos + 8));
|
||||
var dim1 = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos + 12));
|
||||
var dim2 = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(pos + 16));
|
||||
var nameLength = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(pos + 20));
|
||||
pos += FixedHeaderSize;
|
||||
|
||||
@@ -83,7 +109,10 @@ public static class CipSymbolObjectDecoder
|
||||
var isStruct = (symbolType & SymbolTypeStructFlag) != 0;
|
||||
var typeCode = symbolType & SymbolTypeTypeCodeMask;
|
||||
|
||||
var (programScope, simpleName) = SplitProgramScope(name);
|
||||
// A program listing (Program:X.@tags) carries bare names; the scope is the program read.
|
||||
var (programScope, simpleName) = listingScope is null
|
||||
? SplitProgramScope(name)
|
||||
: (listingScope, name);
|
||||
var dataType = isStruct ? AbCipDataType.Structure : MapTypeCode((byte)typeCode);
|
||||
|
||||
// For a struct symbol the lower 12 bits (typeCode) are NOT a primitive CIP type code —
|
||||
@@ -92,18 +121,46 @@ public static class CipSymbolObjectDecoder
|
||||
// carry no template id.
|
||||
var templateInstanceId = isStruct ? (uint?)typeCode : null;
|
||||
|
||||
var (isArray, elementCount) = ArrayShape((symbolType >> SymbolTypeDimsShift) & SymbolTypeDimsMask,
|
||||
dim0, dim1, dim2);
|
||||
|
||||
yield return new AbCipDiscoveredTag(
|
||||
Name: simpleName,
|
||||
ProgramScope: programScope,
|
||||
DataType: dataType ?? AbCipDataType.Structure, // unknown type code → treat as opaque
|
||||
ReadOnly: false, // Symbol Object doesn't carry write-protection bits; lift via AccessControl Object later
|
||||
IsSystemTag: isSystem,
|
||||
ElementCount: elementCount,
|
||||
IsArray: isArray,
|
||||
TemplateInstanceId: templateInstanceId);
|
||||
|
||||
_ = instanceId; // retained in the wire format for diagnostics; not surfaced to the driver today
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Array shape from the symbol type's dimension count (bits 13-14) and the three dimension
|
||||
/// words. A real controller sets no dimension bits on a scalar; <c>ab_server</c> reports every
|
||||
/// scalar as a one-dimensional array of one, so a 1-D single-element entry is taken as a scalar
|
||||
/// too. A multi-dimensional array is surfaced flattened (element count = product of dims) —
|
||||
/// the driver addresses 1-D arrays only.
|
||||
/// </summary>
|
||||
/// <param name="dimCount">Dimension count from bits 13-14 of the symbol type.</param>
|
||||
/// <param name="dim0">First dimension.</param>
|
||||
/// <param name="dim1">Second dimension.</param>
|
||||
/// <param name="dim2">Third dimension.</param>
|
||||
/// <returns>Whether the symbol is an array, and its flattened element count.</returns>
|
||||
internal static (bool IsArray, int ElementCount) ArrayShape(int dimCount, uint dim0, uint dim1, uint dim2)
|
||||
{
|
||||
if (dimCount == 0) return (false, 1);
|
||||
long count = 1;
|
||||
if (dimCount >= 1) count *= Math.Max(1u, dim0);
|
||||
if (dimCount >= 2) count *= Math.Max(1u, dim1);
|
||||
if (dimCount >= 3) count *= Math.Max(1u, dim2);
|
||||
if (dimCount == 1 && count == 1) return (false, 1);
|
||||
return (true, (int)Math.Min(count, int.MaxValue));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Split a <c>Program:MainProgram.StepIndex</c>-style name into its scope + local
|
||||
/// parts. Names without the <c>Program:</c> prefix pass through unchanged.
|
||||
|
||||
@@ -4,103 +4,101 @@ using System.Text;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Decoder for the CIP Template Object (class 0x6C) blob returned by a <c>Read Template</c>
|
||||
/// service. Produces an <see cref="AbCipUdtShape"/> describing the UDT's name, total size,
|
||||
/// + ordered member list with per-member offset + type + array length.
|
||||
/// Decoder for the UDT template buffer libplctag returns for an <c>@udt/<id></c> tag (CIP
|
||||
/// Template Object, class 0x6C). Produces an <see cref="AbCipUdtShape"/> describing the UDT's
|
||||
/// name, total size, + ordered member list with per-member offset + type + array length.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Wire format per Rockwell CIP Vol 1 §5A + Logix 5000 CIP Programming Manual
|
||||
/// 1756-PM019 §"Template Object", cross-checked against libplctag's <c>ab/cip.c</c>
|
||||
/// <c>handle_read_template_reply</c>:</para>
|
||||
/// <para>The layout below is what libplctag hands back from <c>Tag.GetBuffer()</c> — its own
|
||||
/// header followed by the controller's Read Template reply — matching libplctag's
|
||||
/// <c>list_tags_logix</c> example and pinned by real captures in
|
||||
/// <c>CipTemplateObjectWireCaptureTests</c> (CompactLogix 5069-L320ERS2 rev 34.11). An earlier
|
||||
/// version assumed a 12-byte header led by the member count and <c>[info][array][offset]</c>
|
||||
/// blocks; every real buffer decoded to <c>null</c> under that assumption.</para>
|
||||
///
|
||||
/// <para>Header (fixed-size, little-endian):</para>
|
||||
/// <para>Header (14 bytes, little-endian):</para>
|
||||
/// <list type="table">
|
||||
/// <item><term>u16</term><description>Member count.</description></item>
|
||||
/// <item><term>u16</term><description>Struct handle (opaque id).</description></item>
|
||||
/// <item><term>u16</term><description>Template instance id.</description></item>
|
||||
/// <item><term>u32</term><description>Member-definition size in 32-bit words — not used here.</description></item>
|
||||
/// <item><term>u32</term><description>Instance size — bytes per structure instance.</description></item>
|
||||
/// <item><term>u32</term><description>Member-definition total size — not used here.</description></item>
|
||||
/// <item><term>u16</term><description>Member count.</description></item>
|
||||
/// <item><term>u16</term><description>Struct handle (opaque).</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Then <c>member_count</c> member blocks (8 bytes each):</para>
|
||||
/// <list type="table">
|
||||
/// <item><term>u16</term><description>Member info — type code + flags (same encoding
|
||||
/// as Symbol Object: bit 15 = struct, lower 12 = CIP type code).</description></item>
|
||||
/// <item><term>u16</term><description>Array size — 0 for scalar members.</description></item>
|
||||
/// <item><term>u32</term><description>Struct offset — byte offset from struct start.</description></item>
|
||||
/// <item><term>u16</term><description>Info — the array length for an array member, the bit
|
||||
/// number for a BOOL packed into a host integer, otherwise 0.</description></item>
|
||||
/// <item><term>u16</term><description>Type — bit 15 = struct, bit 13 = array, low 12 bits =
|
||||
/// CIP atomic type code, or for a struct the nested template instance id.</description></item>
|
||||
/// <item><term>u32</term><description>Byte offset from the struct start.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Then strings: UDT name followed by each member name, each terminated by a
|
||||
/// semicolon <c>;</c> followed by a null <c>\0</c>. The UDT name may itself contain the
|
||||
/// sequence <c>UDTName;0\0</c> where <c>0</c> after the semicolon is an ASCII flag byte.
|
||||
/// Decoder trims to the first semicolon.</para>
|
||||
/// <para>Then the strings: the UDT name terminated by <c>;</c> (followed by an encoded tail that
|
||||
/// runs to a NUL — discarded), then each member name terminated by a NUL. Anything after the
|
||||
/// last member name is ignored.</para>
|
||||
/// </remarks>
|
||||
public static class CipTemplateObjectDecoder
|
||||
{
|
||||
private const int HeaderSize = 12; // u16 + u16 + u32 + u32
|
||||
private const int MemberBlockSize = 8; // u16 + u16 + u32
|
||||
private const int HeaderSize = 14; // u16 id + u32 def size + u32 instance size + u16 count + u16 handle
|
||||
private const int MemberBlockSize = 8; // u16 info + u16 type + u32 offset
|
||||
|
||||
private const ushort MemberInfoStructFlag = 0x8000;
|
||||
private const ushort MemberInfoTypeCodeMask = 0x0FFF;
|
||||
private const ushort TypeStructFlag = 0x8000;
|
||||
private const ushort TypeArrayFlag = 0x2000;
|
||||
private const ushort TypeCodeMask = 0x0FFF;
|
||||
|
||||
/// <summary>
|
||||
/// Decode the raw Template Object blob. Returns <c>null</c> when the header indicates
|
||||
/// zero members or the buffer is too short to hold the fixed header.
|
||||
/// Decode the raw template buffer. Returns <c>null</c> when the header indicates zero
|
||||
/// members or the buffer is too short to hold the header + member blocks + type name.
|
||||
/// </summary>
|
||||
/// <param name="buffer">The raw Template Object buffer to decode.</param>
|
||||
/// <param name="buffer">The raw template buffer to decode.</param>
|
||||
/// <returns>An AbCipUdtShape describing the structure, or null if decoding fails.</returns>
|
||||
public static AbCipUdtShape? Decode(byte[] buffer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(buffer);
|
||||
if (buffer.Length < HeaderSize) return null;
|
||||
|
||||
var memberCount = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(0));
|
||||
// bytes 2-3: struct handle — opaque, not needed for the shape record
|
||||
var instanceSize = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(4));
|
||||
// bytes 8-11: member-definition total size — inferred from names list instead
|
||||
// bytes 0-1: template instance id; bytes 2-5: definition size in words — neither needed.
|
||||
var instanceSize = BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(6));
|
||||
var memberCount = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(10));
|
||||
// bytes 12-13: struct handle — opaque.
|
||||
|
||||
if (memberCount == 0) return null;
|
||||
|
||||
var memberBlocksOffset = HeaderSize;
|
||||
var namesOffset = memberBlocksOffset + MemberBlockSize * memberCount;
|
||||
var namesOffset = HeaderSize + (MemberBlockSize * memberCount);
|
||||
if (namesOffset > buffer.Length) return null;
|
||||
|
||||
var stringsSpan = buffer.AsSpan(namesOffset);
|
||||
var names = ParseSemicolonTerminatedStrings(stringsSpan);
|
||||
if (names.Count == 0) return null;
|
||||
|
||||
// Strings layout: UDT name first, then one per member (in the same order as the
|
||||
// member-info blocks). Always consume the first entry as the UDT name; missing
|
||||
// trailing member names get <member_N> placeholders below.
|
||||
var udtName = names[0];
|
||||
var memberNames = names.Skip(1).ToArray();
|
||||
var strings = ParseTemplateStrings(buffer.AsSpan(namesOffset), memberCount);
|
||||
if (strings.Count == 0) return null;
|
||||
|
||||
var udtName = strings[0];
|
||||
var members = new List<AbCipUdtMember>(memberCount);
|
||||
for (var i = 0; i < memberCount; i++)
|
||||
{
|
||||
var blockOffset = memberBlocksOffset + (i * MemberBlockSize);
|
||||
var blockOffset = HeaderSize + (i * MemberBlockSize);
|
||||
var info = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset));
|
||||
var arraySize = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset + 2));
|
||||
var type = BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(blockOffset + 2));
|
||||
var offset = (int)BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(blockOffset + 4));
|
||||
|
||||
var isStruct = (info & MemberInfoStructFlag) != 0;
|
||||
// Scalar path only — Rockwell primitive type codes (0xC1-0xD0) fit a byte. Do NOT use for a
|
||||
// struct member: a nested template id can exceed a byte (see nestedTemplateId below).
|
||||
var typeCode = (byte)(info & MemberInfoTypeCodeMask);
|
||||
var isStruct = (type & TypeStructFlag) != 0;
|
||||
var isArray = (type & TypeArrayFlag) != 0;
|
||||
// Atomic CIP type codes (0xC1-0xD0) fit a byte; for a struct the low 12 bits are the nested
|
||||
// template id, which can exceed a byte, so it is taken from the full 12-bit mask below.
|
||||
var dataType = isStruct
|
||||
? AbCipDataType.Structure
|
||||
: (CipSymbolObjectDecoder.MapTypeCode(typeCode) ?? AbCipDataType.Structure);
|
||||
: (CipSymbolObjectDecoder.MapTypeCode((byte)(type & TypeCodeMask)) ?? AbCipDataType.Structure);
|
||||
var nestedTemplateId = isStruct ? (uint?)(type & TypeCodeMask) : null;
|
||||
|
||||
// For a struct member the low 12 bits are the nested UDT's template instance id (same encoding as the
|
||||
// Symbol Object), NOT a primitive type code — capture it so the nested shape can be fetched. Use the
|
||||
// FULL 12-bit mask (not the byte-cast typeCode, which truncates a 12-bit id).
|
||||
var nestedTemplateId = isStruct ? (uint?)(info & MemberInfoTypeCodeMask) : null;
|
||||
// info is the element count only for an array member; for a BOOL it is the bit number,
|
||||
// which must never be read as a length.
|
||||
var arrayLength = isArray && info > 0 ? info : 1;
|
||||
|
||||
var memberName = i < memberNames.Length ? memberNames[i] : $"<member_{i}>";
|
||||
var memberName = i + 1 < strings.Count ? strings[i + 1] : $"<member_{i}>";
|
||||
members.Add(new AbCipUdtMember(
|
||||
Name: memberName,
|
||||
Offset: offset,
|
||||
DataType: dataType,
|
||||
ArrayLength: arraySize == 0 ? 1 : arraySize,
|
||||
ArrayLength: arrayLength,
|
||||
NestedTemplateId: nestedTemplateId));
|
||||
}
|
||||
|
||||
@@ -111,41 +109,30 @@ public static class CipTemplateObjectDecoder
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk a span of <c>NAME;\0NAME;\0…</c> byte sequences. Splits at each semicolon —
|
||||
/// the null byte after each semicolon is optional padding per Rockwell's string
|
||||
/// encoding convention. Stops at a trailing null / end of buffer.
|
||||
/// Split the template string section into the UDT name (cut at its first <c>;</c>) followed
|
||||
/// by up to <paramref name="memberCount"/> NUL-terminated member names. Stops at the end of
|
||||
/// the buffer; trailing bytes past the last member name are ignored.
|
||||
/// </summary>
|
||||
/// <param name="span">The byte span containing semicolon-terminated strings.</param>
|
||||
/// <returns>List of parsed strings, one per name in the span.</returns>
|
||||
internal static List<string> ParseSemicolonTerminatedStrings(ReadOnlySpan<byte> span)
|
||||
/// <param name="span">The string section, starting right after the last member block.</param>
|
||||
/// <param name="memberCount">Member names to read after the UDT name.</param>
|
||||
/// <returns>The UDT name followed by the member names found.</returns>
|
||||
internal static List<string> ParseTemplateStrings(ReadOnlySpan<byte> span, int memberCount)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var start = 0;
|
||||
for (var i = 0; i < span.Length; i++)
|
||||
var result = new List<string>(memberCount + 1);
|
||||
var pos = 0;
|
||||
while (pos < span.Length && result.Count < memberCount + 1)
|
||||
{
|
||||
var b = span[i];
|
||||
if (b == ';')
|
||||
var nul = span[pos..].IndexOf((byte)0);
|
||||
var end = nul < 0 ? span.Length : pos + nul;
|
||||
var raw = span[pos..end];
|
||||
if (result.Count == 0)
|
||||
{
|
||||
if (i > start)
|
||||
result.Add(Encoding.ASCII.GetString(span[start..i]));
|
||||
// Skip the optional null/space padding following the semicolon.
|
||||
while (i + 1 < span.Length && (span[i + 1] == '\0' || span[i + 1] == ' '))
|
||||
i++;
|
||||
start = i + 1;
|
||||
// The type name carries an encoded tail after ';' ("STRING;nEAIA").
|
||||
var semi = raw.IndexOf((byte)';');
|
||||
if (semi >= 0) raw = raw[..semi];
|
||||
}
|
||||
else if (b == 0 && start == i)
|
||||
{
|
||||
// Trailing null at a string boundary — done.
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Trailing name without a semicolon (unlikely but observed on some firmwares).
|
||||
if (start < span.Length)
|
||||
{
|
||||
var zeroAt = span[start..].IndexOf((byte)0);
|
||||
var end = zeroAt < 0 ? span.Length : start + zeroAt;
|
||||
if (end > start)
|
||||
result.Add(Encoding.ASCII.GetString(span[start..end]));
|
||||
result.Add(Encoding.ASCII.GetString(raw));
|
||||
pos = end + 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,13 @@ public interface IAbCipTagRuntime : IDisposable
|
||||
/// <returns>The raw libplctag status code (zero on success, negative on error).</returns>
|
||||
int GetStatus();
|
||||
|
||||
/// <summary>
|
||||
/// Bytes in the last read's buffer, or <c>-1</c> when the runtime cannot say. Used to catch an
|
||||
/// authored type whose width disagrees with what the controller actually returned.
|
||||
/// </summary>
|
||||
/// <returns>The buffer size in bytes, or -1 if unknown.</returns>
|
||||
int GetSize() => -1;
|
||||
|
||||
/// <summary>
|
||||
/// Decode the local buffer into a boxed .NET value per the tag's configured type.
|
||||
/// <paramref name="bitIndex"/> is non-null only for BOOL-within-DINT tags captured in
|
||||
|
||||
@@ -19,29 +19,73 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// </remarks>
|
||||
internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
||||
{
|
||||
private Tag? _tag;
|
||||
private readonly Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> _readListing;
|
||||
|
||||
/// <summary>Production enumerator: reads each listing through libplctag.</summary>
|
||||
public LibplctagTagEnumerator() : this(ReadListingAsync) { }
|
||||
|
||||
/// <summary>Enumerator over an injected listing reader (unit-test seam).</summary>
|
||||
/// <param name="readListing">Reads one symbol listing (<c>@tags</c> or <c>Program:X.@tags</c>) and
|
||||
/// returns its raw Symbol Object bytes.</param>
|
||||
internal LibplctagTagEnumerator(Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> readListing)
|
||||
=> _readListing = readListing;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A controller's <c>@tags</c> listing holds controller-scope tags plus one system entry per
|
||||
/// program (<c>Program:UN01_Press</c>); the program's own tags are listed only by
|
||||
/// <c>Program:<name>.@tags</c>. So the controller listing is read first, then each program's
|
||||
/// listing, decoded with that program as the scope. A program whose listing cannot be read is
|
||||
/// skipped — browse is best-effort and one unreadable program must not cost the others.
|
||||
/// </remarks>
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
_tag = BuildSymbolListTag(deviceParams);
|
||||
var buffer = await _readListing(deviceParams, "@tags", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var buffer = _tag.GetBuffer();
|
||||
// The decoder already surfaces each struct tag's CIP template instance id on
|
||||
// AbCipDiscoveredTag.TemplateInstanceId (from the lower 12 bits of the symbol type), so
|
||||
// re-yielding the decoded records as-is carries it through to DiscoverAsync's UDT fan-out.
|
||||
var programs = new List<string>();
|
||||
foreach (var tag in CipSymbolObjectDecoder.Decode(buffer))
|
||||
{
|
||||
if (CipSymbolObjectDecoder.ProgramNameOf(tag) is { } program) programs.Add(program);
|
||||
yield return tag;
|
||||
}
|
||||
|
||||
foreach (var program in programs)
|
||||
{
|
||||
byte[] programBuffer;
|
||||
try
|
||||
{
|
||||
programBuffer = await _readListing(deviceParams, $"Program:{program}.@tags", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var tag in CipSymbolObjectDecoder.Decode(programBuffer, programScope: program))
|
||||
yield return tag;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadListingAsync(
|
||||
AbCipTagCreateParams deviceParams, string listingName, CancellationToken cancellationToken)
|
||||
{
|
||||
using var tag = BuildSymbolListTag(deviceParams, listingName);
|
||||
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return tag.GetBuffer();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the (uninitialized) libplctag tag for the <c>@tags</c> pseudo — same gateway +
|
||||
/// path as the device, distinguished by the name alone.
|
||||
/// Builds the (uninitialized) libplctag tag for a symbol listing pseudo-tag — <c>@tags</c> or
|
||||
/// <c>Program:<name>.@tags</c> — same gateway + path as the device, distinguished by the
|
||||
/// name alone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Gateway must be <see cref="AbCipTagCreateParams.LibplctagGateway"/> (host:port), never the
|
||||
@@ -49,18 +93,21 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
||||
/// host, so a browse against a device authored on another port silently returns whatever
|
||||
/// answers on 44818 (Gitea #539, the discovery half of #534).
|
||||
/// </remarks>
|
||||
internal static Tag BuildSymbolListTag(AbCipTagCreateParams deviceParams) => new()
|
||||
/// <param name="deviceParams">The device's create params.</param>
|
||||
/// <param name="listingName">The listing pseudo-tag name (default <c>@tags</c>).</param>
|
||||
/// <returns>The uninitialized tag.</returns>
|
||||
internal static Tag BuildSymbolListTag(AbCipTagCreateParams deviceParams, string listingName = "@tags") => new()
|
||||
{
|
||||
Gateway = deviceParams.LibplctagGateway,
|
||||
Path = deviceParams.CipPath,
|
||||
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
|
||||
Protocol = Protocol.ab_eip,
|
||||
Name = "@tags",
|
||||
Name = listingName,
|
||||
Timeout = deviceParams.Timeout,
|
||||
};
|
||||
|
||||
/// <summary>Disposes the enumerator and releases the underlying libplctag tag.</summary>
|
||||
public void Dispose() => _tag?.Dispose();
|
||||
/// <summary>Nothing to release — each listing's libplctag tag is disposed after its read.</summary>
|
||||
public void Dispose() { }
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
{
|
||||
|
||||
@@ -46,6 +46,9 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
/// <inheritdoc />
|
||||
public int GetStatus() => (int)_tag.GetStatus();
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetSize() => _tag.GetSize();
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
|
||||
|
||||
@@ -79,8 +82,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
// accounts for STRING capacity + atomic widths. ElementSize is int? on the wrapper — fall
|
||||
// back to a static size table when it is null/<=0 (e.g. a zero-length read) so the stride
|
||||
// is never 0.
|
||||
var elementSize = _tag.ElementSize ?? 0;
|
||||
var stride = elementSize > 0 ? elementSize : ElementByteSize(type);
|
||||
var stride = ResolveArrayStride(_tag.ElementSize, _tag.GetSize(), count, type);
|
||||
|
||||
return type switch
|
||||
{
|
||||
@@ -99,6 +101,26 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Byte stride between array elements. libplctag's <c>ElementSize</c> wins when populated, but
|
||||
/// after a read it is <c>null</c> (observed live, libplctag.NET 1.5.2) — and the static table
|
||||
/// assumes the built-in 88-byte STRING, which is wrong for a user string type: a <c>STR0016</c>
|
||||
/// element is 20 bytes, so element 1 was read at offset 88 of an 80-byte buffer and the whole
|
||||
/// read failed. When the read buffer divides evenly by the element count, that quotient IS the
|
||||
/// element size, so it is used before the static table.
|
||||
/// </summary>
|
||||
/// <param name="elementSize">libplctag's reported element size, if any.</param>
|
||||
/// <param name="bufferSize">Bytes in the read buffer.</param>
|
||||
/// <param name="count">Element count requested.</param>
|
||||
/// <param name="type">Element type.</param>
|
||||
/// <returns>The stride in bytes.</returns>
|
||||
internal static int ResolveArrayStride(int? elementSize, int bufferSize, int count, AbCipDataType type)
|
||||
{
|
||||
if (elementSize is > 0) return elementSize.Value;
|
||||
if (count > 0 && bufferSize > 0 && bufferSize % count == 0) return bufferSize / count;
|
||||
return ElementByteSize(type);
|
||||
}
|
||||
|
||||
private bool[] BuildBoolArray(int count)
|
||||
{
|
||||
var arr = new bool[count];
|
||||
|
||||
Reference in New Issue
Block a user