fix(focas): disambiguate the cnc_rdalmmsg2 record layout at 1,520-byte multiples (R07:H20)
ParseAlarms took the 76-byte vendor layout whenever Length % 76 == 0, so a legacy 80-byte reply of exactly 19 records (1,520 B = 20 x 76) decoded as 20 garbage vendor records. The response block carries no record count (its 16-byte header is length/class/path/ command/rc/payload length), so the layout is chosen from what is known: the record count the request asked for (a reply never holds more), then each layout's field invariants on every record. For legacy records, the int32 type/axis/length must fit a short and the length must be at most 64. For vendor records, the i16 length must be at most 64. If both still fit, the layout whose declared lengths match the decoded text wins. Vendor stays the default when both readings are equally plausible, and unambiguous lengths are unchanged. Refs #740 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -1137,15 +1137,88 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <param name="count">The requested record count.</param>
|
||||
/// <returns>The decoded alarms.</returns>
|
||||
/// <exception cref="FocasWireException">The payload length matches neither record layout.</exception>
|
||||
/// <remarks>
|
||||
/// <para><b>Choosing the layout.</b> The response block carries no record count, so a length that is a
|
||||
/// multiple of BOTH strides (1,520 B = 20 × 76 = 19 × 80, and its multiples) is ambiguous; picking the
|
||||
/// vendor stride on <c>Length % 76 == 0</c> alone decoded 19 legacy records as 20 garbage ones. The choice
|
||||
/// now uses what IS known: (1) the record count the REQUEST asked for — a reply never holds more, so a
|
||||
/// layout implying more records than <paramref name="count"/> is ruled out; (2) each layout's own field
|
||||
/// invariants, checked on every record (<see cref="LegacyRecordsValid"/> / <see cref="VendorRecordsValid"/>).
|
||||
/// Only when both readings remain plausible does the vendor layout win, as before.</para>
|
||||
/// </remarks>
|
||||
internal static IReadOnlyList<WireAlarm> ParseAlarms(byte[] payload, short count)
|
||||
{
|
||||
if (payload.Length % 76 == 0) return ParseVendorAlarms(payload, count);
|
||||
if (payload.Length % 80 == 0) return ParseLegacyWireAlarms(payload, count);
|
||||
if (payload.Length == 0) return [];
|
||||
var vendor = payload.Length % VendorAlarmStride == 0 && payload.Length / VendorAlarmStride <= Math.Max((int)count, 1);
|
||||
var legacy = payload.Length % LegacyAlarmStride == 0 && payload.Length / LegacyAlarmStride <= Math.Max((int)count, 1);
|
||||
if (vendor && legacy)
|
||||
{
|
||||
// Ambiguous length: a legacy record's int32 type/axis/length fields must each fit the short they are
|
||||
// read into and its length must fit the 64-byte message — a vendor payload read at the legacy stride
|
||||
// fails that on its first non-empty message. Prefer the reading whose every record is self-consistent.
|
||||
var legacyOk = LegacyRecordsValid(payload);
|
||||
var vendorOk = VendorRecordsValid(payload);
|
||||
if (legacyOk && !vendorOk) vendor = false;
|
||||
else if (legacyOk && vendorOk && LengthsMatchText(payload, LegacyAlarmStride, 12, 4, 16)
|
||||
&& !LengthsMatchText(payload, VendorAlarmStride, 10, 2, 12)) vendor = false;
|
||||
}
|
||||
if (vendor) return ParseVendorAlarms(payload, count);
|
||||
if (legacy) return ParseLegacyWireAlarms(payload, count);
|
||||
if (payload.Length % VendorAlarmStride == 0 || payload.Length % LegacyAlarmStride == 0)
|
||||
{
|
||||
// Fits a stride but holds more records than were requested: fall back to the stride it fits, capped
|
||||
// at the count, exactly as before this check existed.
|
||||
return payload.Length % VendorAlarmStride == 0
|
||||
? ParseVendorAlarms(payload, count)
|
||||
: ParseLegacyWireAlarms(payload, count);
|
||||
}
|
||||
throw new FocasWireException(
|
||||
$"cnc_rdalmmsg2 returned a {payload.Length}-byte payload, which fits neither the 76- nor the 80-byte " +
|
||||
"record layout; refusing to read it as an empty alarm list.", rc: null);
|
||||
}
|
||||
|
||||
private const int VendorAlarmStride = 76;
|
||||
private const int LegacyAlarmStride = 80;
|
||||
private const int AlarmMessageBytes = 64;
|
||||
|
||||
/// <summary>Every 80-byte record's int32 type / axis / length fit a short, and the length fits the message.</summary>
|
||||
private static bool LegacyRecordsValid(byte[] payload)
|
||||
{
|
||||
for (var o = 0; o + LegacyAlarmStride <= payload.Length; o += LegacyAlarmStride)
|
||||
{
|
||||
var type = ReadInt32(payload, o + 4);
|
||||
var axis = ReadInt32(payload, o + 8);
|
||||
var length = ReadInt32(payload, o + 12);
|
||||
if (type is < short.MinValue or > short.MaxValue) return false;
|
||||
if (axis is < short.MinValue or > short.MaxValue) return false;
|
||||
if (length is < 0 or > AlarmMessageBytes) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Every 76-byte record's i16 message length fits the 64-byte message.</summary>
|
||||
private static bool VendorRecordsValid(byte[] payload)
|
||||
{
|
||||
for (var o = 0; o + VendorAlarmStride <= payload.Length; o += VendorAlarmStride)
|
||||
{
|
||||
var length = ReadInt16(payload, o + 10);
|
||||
if (length is < 0 or > AlarmMessageBytes) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Every record's declared message length equals the length of the text actually in its message field.</summary>
|
||||
private static bool LengthsMatchText(byte[] payload, int stride, int lengthOffset, int lengthWidth, int messageOffset)
|
||||
{
|
||||
for (var o = 0; o + stride <= payload.Length; o += stride)
|
||||
{
|
||||
var length = lengthWidth == 4 ? ReadInt32(payload, o + lengthOffset) : ReadInt16(payload, o + lengthOffset);
|
||||
var text = FocasWireProtocol.ReadAscii(payload.AsSpan(o + messageOffset, AlarmMessageBytes));
|
||||
if (length != text.Length) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<WireAlarm> ParseVendorAlarms(byte[] payload, short count)
|
||||
{
|
||||
var alarms = new List<WireAlarm>();
|
||||
|
||||
Reference in New Issue
Block a user