fix(dcl): pad List writes to the Galaxy array's full declared size

Even with correct array encoding (30d07b9), Ipsen MoveIn array writes still
hung: the Galaxy MES-receiver arrays are fixed-size SAFEARRAYs (e.g.
MoveInWorkOrderNumbers = SAFEARRAY(VT_BSTR) dimensions:[50]) and MXAccess only
accepts a write that supplies ALL slots. ScadaBridge sent just the N elements
the MES provided (1-2), so the COM write blocked. Verified on the live gateway:
a full-size (50) constructed array writes via WriteBulk in ~34ms; a short one
does not.

RealMxGatewayClient.WriteAsync now, for a list value, reads the tag's current
array to learn its slot count and pads the value to that length with
element-type defaults (empty string / 0 / false / default) — the caller's
values fill slots 0..N-1, the rest are cleared. The PLC reads the valid count
from a separate scalar (MoveInNumberWorkOrders). If the size can't be
determined (read fails / not an array) the value is written unpadded and a
warning is logged. Scalars are unaffected.
This commit is contained in:
Joseph Doherty
2026-06-17 06:34:05 -04:00
parent 30d07b91f4
commit 45b23476fc
@@ -150,10 +150,16 @@ public sealed class RealMxGatewayClient : IMxGatewayClient
// supervisory subscription may already cover this handle — advise-once).
if (UseSupervisoryAdvise)
await EnsureSupervisoryAdvisedAsync(handle, ct).ConfigureAwait(false);
// MXAccess SAFEARRAY writes must supply the attribute's FULL declared
// length; a short array (only the N elements the caller provided) is
// rejected and the COM write blocks. Pad list values out to the live
// array's slot count before encoding (callers signal the valid count
// via a separate scalar, e.g. MoveInNumberWorkOrders).
var toWrite = await PadArrayToDeclaredSizeAsync(tag, value, ct).ConfigureAwait(false);
entries.Add(new WriteBulkEntry
{
ItemHandle = handle,
Value = ToMxValue(value),
Value = ToMxValue(toWrite),
UserId = _writeUserId,
});
orderedTags.Add(tag);
@@ -356,6 +362,76 @@ public sealed class RealMxGatewayClient : IMxGatewayClient
return handle;
}
/// <summary>
/// MXAccess fixed-size SAFEARRAY attributes only accept a write that supplies
/// the whole array; a short list (just the caller's N elements) is rejected and
/// the COM write blocks. For a list value, read the tag's current array to learn
/// its slot count and pad the value to that length with element-type defaults
/// (empty string / 0 / false / default) — the caller's values fill slots 0..N-1,
/// the remainder are cleared. Scalars (and a non-array / unreadable tag) pass
/// through unchanged.
/// </summary>
private async Task<object?> PadArrayToDeclaredSizeAsync(string tag, object? value, CancellationToken ct)
{
switch (value)
{
case IReadOnlyList<string> v: return await PadAsync(tag, v, string.Empty, ct).ConfigureAwait(false);
case IReadOnlyList<int> v: return await PadAsync(tag, v, 0, ct).ConfigureAwait(false);
case IReadOnlyList<long> v: return await PadAsync(tag, v, 0L, ct).ConfigureAwait(false);
case IReadOnlyList<float> v: return await PadAsync(tag, v, 0f, ct).ConfigureAwait(false);
case IReadOnlyList<double> v: return await PadAsync(tag, v, 0d, ct).ConfigureAwait(false);
case IReadOnlyList<bool> v: return await PadAsync(tag, v, false, ct).ConfigureAwait(false);
case IReadOnlyList<DateTimeOffset> v: return await PadAsync(tag, v, default(DateTimeOffset), ct).ConfigureAwait(false);
case IReadOnlyList<DateTime> v: return await PadAsync(tag, v, default(DateTime), ct).ConfigureAwait(false);
default: return value; // scalar or unsupported — leave as-is
}
}
private async Task<IReadOnlyList<T>> PadAsync<T>(string tag, IReadOnlyList<T> values, T pad, CancellationToken ct)
{
var size = await GetArraySlotCountAsync(tag, ct).ConfigureAwait(false);
if (size <= 0)
{
// Couldn't determine the declared size (read failed or tag is not an
// array). Write what we have rather than blocking the caller; if the
// node really is a short SAFEARRAY the gateway will surface the error.
_logger.LogWarning("Could not determine array size for '{Tag}'; writing {Count} element(s) unpadded", tag, values.Count);
return values;
}
if (values.Count > size)
_logger.LogWarning("Array write for '{Tag}' has {Count} elements but the node holds {Size}; truncating", tag, values.Count, size);
var result = new List<T>(size);
for (var i = 0; i < size; i++)
result.Add(i < values.Count ? values[i] : pad);
return result;
}
/// <summary>Reads the tag's current array element count (its declared SAFEARRAY length), or 0 if not an array / unreadable.</summary>
private async Task<int> GetArraySlotCountAsync(string tag, CancellationToken ct)
{
var reads = await _session!
.ReadBulkAsync(_serverHandle, new[] { tag }, TimeSpan.FromMilliseconds(_readTimeoutMs), ct)
.ConfigureAwait(false);
var v = reads.Count > 0 ? reads[0].Value : null;
if (v is null || v.KindCase != MxValue.KindOneofCase.ArrayValue)
return 0;
var a = v.ArrayValue;
return a.ValuesCase switch
{
MxArray.ValuesOneofCase.StringValues => a.StringValues.Values.Count,
MxArray.ValuesOneofCase.Int32Values => a.Int32Values.Values.Count,
MxArray.ValuesOneofCase.Int64Values => a.Int64Values.Values.Count,
MxArray.ValuesOneofCase.FloatValues => a.FloatValues.Values.Count,
MxArray.ValuesOneofCase.DoubleValues => a.DoubleValues.Values.Count,
MxArray.ValuesOneofCase.BoolValues => a.BoolValues.Values.Count,
MxArray.ValuesOneofCase.TimestampValues => a.TimestampValues.Values.Count,
_ => 0,
};
}
/// <summary>
/// Ensures the item is advised in MXAccess SUPERVISORY mode (advise-once). A
/// supervisory advise is what lets the gateway accept a write and it still