feat(ablegacy): encode array writes element by element
Review 07 U10 (user decision: implement array encode now). Both runtimes encoded a scalar for an array tag: an int[] threw InvalidCastException into BadTypeMismatch + Degraded, a scalar wrote element 0 natively, and libplctag's SetInt32(0, ..) re-sent the stale last-read buffer for elements 1..n-1. IAbLegacyTagRuntime gains EncodeArray; both runtimes write every element at the element width (Int/AnalogInt 2 bytes, Long/Float 4). The driver converts the written value before any runtime exists: each element is converted, not exact-matched (ScadaBridge writes int[] to Int16 nodes), a length other than the tag's element count is BadOutOfRange, an overflowing element is BadOutOfRange, a non-array or non-numeric payload is BadTypeMismatch, and a Bit array (bit-packed words; an encode would clobber neighbour bits) is BadNotSupported. None of these touch health or the wire. Adds an env-gated ab_server round trip for an N-file array write. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
using System.Collections;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OPC UA array-write value into the element-typed CLR array a runtime's
|
||||
/// <see cref="IAbLegacyTagRuntime.EncodeArray"/> takes. Pure: it runs BEFORE any runtime is created, so a refused
|
||||
/// write never costs a wire round trip and never touches driver health.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Elements are converted, not exact-matched.</b> ScadaBridge writes CLR lists such as <c>int[]</c> to
|
||||
/// Int16 nodes, and an OPC UA client may send any numeric array, so each element goes through the same checked
|
||||
/// <see cref="Convert"/> the scalar path uses. A value that does not fit is <c>BadOutOfRange</c>; one that is not
|
||||
/// a number at all is <c>BadTypeMismatch</c>.</para>
|
||||
/// <para><b>The length must equal the tag's element count</b> (<c>BadOutOfRange</c> otherwise): a shorter write
|
||||
/// would have to fill the tail from the last read buffer, which is the stale-elements defect this replaces.</para>
|
||||
/// <para><b>Bit arrays are refused</b> (<c>BadNotSupported</c>): a PCCC bit array is bit-packed into words, so
|
||||
/// encoding it rewrites whole words and would clobber neighbouring bits the PLC owns. String / Timer / Counter /
|
||||
/// Control arrays never reach here — the mapper rejects them.</para>
|
||||
/// </remarks>
|
||||
internal static class AbLegacyArrayWriteEncoder
|
||||
{
|
||||
/// <summary>Converts <paramref name="value"/> to a typed array of exactly <paramref name="count"/> elements.</summary>
|
||||
/// <param name="type">The tag's element data type.</param>
|
||||
/// <param name="value">The value the client wrote.</param>
|
||||
/// <param name="count">The tag's element count.</param>
|
||||
/// <param name="typed">The converted array when the result is Good.</param>
|
||||
/// <returns><see cref="AbLegacyStatusMapper.Good"/>, or the Bad status the write is answered with.</returns>
|
||||
public static uint TryConvert(AbLegacyDataType type, object? value, int count, out Array? typed)
|
||||
{
|
||||
typed = null;
|
||||
if (type is AbLegacyDataType.Bit) return AbLegacyStatusMapper.BadNotSupported;
|
||||
if (type is not (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float))
|
||||
return AbLegacyStatusMapper.BadNotSupported;
|
||||
if (value is null or string || value is not IEnumerable items) return AbLegacyStatusMapper.BadTypeMismatch;
|
||||
|
||||
var elements = items.Cast<object?>().ToList();
|
||||
if (elements.Count != count) return AbLegacyStatusMapper.BadOutOfRange;
|
||||
try
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AbLegacyDataType.Long:
|
||||
{
|
||||
var a = new int[count];
|
||||
for (var i = 0; i < count; i++) a[i] = Convert.ToInt32(Element(elements[i]));
|
||||
typed = a;
|
||||
break;
|
||||
}
|
||||
case AbLegacyDataType.Float:
|
||||
{
|
||||
var a = new float[count];
|
||||
for (var i = 0; i < count; i++) a[i] = Convert.ToSingle(Element(elements[i]));
|
||||
typed = a;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
var a = new short[count];
|
||||
for (var i = 0; i < count; i++) a[i] = Convert.ToInt16(Element(elements[i]));
|
||||
typed = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return AbLegacyStatusMapper.Good;
|
||||
}
|
||||
catch (OverflowException) { return AbLegacyStatusMapper.BadOutOfRange; }
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException) { return AbLegacyStatusMapper.BadTypeMismatch; }
|
||||
}
|
||||
|
||||
// A string element is not a number: Convert.ToInt16("12") would PARSE it, which no OPC UA numeric array carries.
|
||||
private static object Element(object? e) => e switch
|
||||
{
|
||||
null => throw new InvalidCastException("null array element"),
|
||||
string => throw new InvalidCastException("string array element"),
|
||||
bool => throw new InvalidCastException("bool array element"),
|
||||
_ => e,
|
||||
};
|
||||
}
|
||||
@@ -612,7 +612,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
// READ, and against a device lacking that file the read failure surfaced as a comms fault —
|
||||
// BadCommunicationError, evicted handle, Degraded health — instead of the refusal.
|
||||
// Ahead of the bit-RMW branch too, which would otherwise read the parent word first.
|
||||
if (parsed is not null && _tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is { } refusal)
|
||||
if (_tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is { } refusal)
|
||||
{
|
||||
WarnRefusalOnce(device, def, "write", refusal);
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
|
||||
@@ -627,7 +627,23 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
// sub-element combined with a /N bit index, so their BitIndex is always null. NOTE: an Input-image (I) write is sent
|
||||
// to the PLC like any other write — the device drives the input image from physical inputs and
|
||||
// may reject it; we surface that real PCCC status rather than pre-rejecting at the driver.
|
||||
if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit)
|
||||
// Array writes (review 07 U10): convert and length-check BEFORE any runtime exists, then encode every
|
||||
// element. A bit / sub-element array has no flat encoding.
|
||||
Array? arrayValue = null;
|
||||
if (IsArrayTag(def))
|
||||
{
|
||||
var conversion = parsed.BitIndex is not null || parsed.SubElement is not null
|
||||
? AbLegacyStatusMapper.BadNotSupported
|
||||
: AbLegacyArrayWriteEncoder.TryConvert(def.DataType, w.Value, EffectiveArrayLength(def), out arrayValue);
|
||||
if (conversion != AbLegacyStatusMapper.Good)
|
||||
{
|
||||
if (conversion == AbLegacyStatusMapper.BadNotSupported)
|
||||
WarnRefusalOnce(device, def, "write", $"{def.DataType} array writes are not supported.");
|
||||
results[i] = new WriteResult(conversion);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (def.DataType == AbLegacyDataType.Bit && parsed.BitIndex is int bit)
|
||||
{
|
||||
results[i] = new WriteResult(
|
||||
await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false));
|
||||
@@ -643,7 +659,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value);
|
||||
if (arrayValue is not null) runtime.EncodeArray(def.DataType, arrayValue);
|
||||
else runtime.EncodeValue(def.DataType, parsed.BitIndex, w.Value);
|
||||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
||||
status = runtime.GetStatus();
|
||||
}
|
||||
|
||||
@@ -50,6 +50,20 @@ public interface IAbLegacyTagRuntime : IDisposable
|
||||
/// <param name="bitIndex">Optional bit index for bit-level access.</param>
|
||||
/// <param name="value">The value to encode.</param>
|
||||
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
|
||||
|
||||
/// <summary>
|
||||
/// Encodes EVERY element of a multi-element tag for the next <see cref="WriteAsync"/>, at the element width
|
||||
/// of <paramref name="type"/>. <paramref name="values"/> is already element-typed by the driver
|
||||
/// (<c>short[]</c> for Int/AnalogInt, <c>int[]</c> for Long, <c>float[]</c> for Float) and its length must
|
||||
/// equal the runtime's element count (<see cref="AbLegacyTagCreateParams.ElementCount"/>), so no element of
|
||||
/// the write comes from a stale read buffer. Bit and structured element arrays are not encodable.
|
||||
/// </summary>
|
||||
/// <param name="type">The PCCC element data type.</param>
|
||||
/// <param name="values">The typed elements, exactly element-count long.</param>
|
||||
/// <exception cref="ArgumentException">The length differs from the element count, or the array's element type
|
||||
/// does not match <paramref name="type"/>.</exception>
|
||||
/// <exception cref="NotSupportedException"><paramref name="type"/> has no flat array encoding.</exception>
|
||||
void EncodeArray(AbLegacyDataType type, Array values);
|
||||
}
|
||||
|
||||
public interface IAbLegacyTagFactory
|
||||
|
||||
@@ -15,6 +15,9 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
{
|
||||
private readonly Tag _tag;
|
||||
|
||||
/// <summary>The element count the tag was created with (1 for a scalar).</summary>
|
||||
private readonly int _count;
|
||||
|
||||
/// <summary>Why a write to this tag is refused before the wire (<see cref="WriteRefusal"/>), else null.</summary>
|
||||
private readonly string? _writeRefusal;
|
||||
|
||||
@@ -32,6 +35,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
|
||||
{
|
||||
_tag = BuildTag(p);
|
||||
_count = Math.Max(1, p.ElementCount);
|
||||
_writeRefusal = WriteRefusal(p);
|
||||
}
|
||||
|
||||
@@ -244,6 +248,32 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EncodeArray(AbLegacyDataType type, Array values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
if (values.Length != _count)
|
||||
throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values));
|
||||
// Same offsets DecodeArray reads. EVERY element is set, so the buffer libplctag sends holds nothing from the
|
||||
// last read (a scalar Set*(0, ...) used to re-send elements 1..n-1 as they were last read).
|
||||
switch (type, values)
|
||||
{
|
||||
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s):
|
||||
for (var i = 0; i < s.Length; i++) _tag.SetInt16(i * 2, s[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Long, int[] l):
|
||||
for (var i = 0; i < l.Length; i++) _tag.SetInt32(i * 4, l[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Float, float[] f):
|
||||
for (var i = 0; i < f.Length; i++) _tag.SetFloat32(i * 4, f[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _):
|
||||
throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values));
|
||||
default:
|
||||
throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Disposes the tag and releases its resources.</summary>
|
||||
public void Dispose() => _tag.Dispose();
|
||||
|
||||
|
||||
@@ -448,6 +448,32 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EncodeArray(AbLegacyDataType type, Array values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
if (values.Length != _count)
|
||||
throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values));
|
||||
// Same layout as DecodeArray: element i at i * elementSize. Every element is overwritten, so nothing the
|
||||
// write sends comes from the last read.
|
||||
switch (type, values)
|
||||
{
|
||||
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s):
|
||||
for (var i = 0; i < s.Length; i++) BinaryPrimitives.WriteInt16LittleEndian(SliceW(i * 2, 2), s[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Long, int[] l):
|
||||
for (var i = 0; i < l.Length; i++) BinaryPrimitives.WriteInt32LittleEndian(SliceW(i * 4, 4), l[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Float, float[] f):
|
||||
for (var i = 0; i < f.Length; i++) BinaryPrimitives.WriteSingleLittleEndian(SliceW(i * 4, 4), f[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _):
|
||||
throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values));
|
||||
default:
|
||||
throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the lease on the shared device connection (the last release closes it) without waiting:
|
||||
/// closing does I/O (Forward Close / UnRegisterSession) and the driver disposes under a per-tag lock.
|
||||
|
||||
Reference in New Issue
Block a user