Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasAlarmHistoryDecoder.cs
Joseph Doherty 7f9d6a778e Auto: focas-f3a — cnc_rdalmhistry alarm-history extension
Adds FocasAlarmProjection with two modes (ActiveOnly default, ActivePlusHistory)
that polls cnc_rdalmhistry on connect + on a configurable cadence (5 min default,
HistoryDepth=100 capped at 250). Emits historic events via IAlarmSource with
SourceTimestampUtc set from the CNC's reported timestamp; dedup keyed on
(OccurrenceTime, AlarmNumber, AlarmType). Ships the ODBALMHIS packed-buffer
decoder + encoder in Wire/FocasAlarmHistoryDecoder.cs and threads
ReadAlarmHistoryAsync through IFocasClient (default no-op so existing transport
variants stay back-compat). FocasDriver now implements IAlarmSource.

13 new unit tests cover: mode switch, dedup, distinct-timestamp emission,
type-as-key behaviour, OccurrenceTime passthrough (not Now), HistoryDepth
clamp/fallback, and decoder round-trip. All 341 FOCAS unit tests still pass.

Docs: docs/drivers/FOCAS.md (new), docs/v2/focas-deployment.md (new),
docs/v2/implementation/focas-wire-protocol.md (new),
docs/v2/implementation/focas-simulator-plan.md (new),
docs/drivers/FOCAS-Test-Fixture.md (alarm-history bullet appended).

Closes #267
2026-04-26 00:07:59 -04:00

183 lines
9.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Buffers.Binary;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// FWLIB <c>ODBALMHIS</c> struct decoder for the <c>cnc_rdalmhistry</c> alarm-history
/// extension (issue #267, plan PR F3-a). Documents + decodes the historical-alarm
/// payload returned by FANUC controllers when asked for the most-recent N ring-buffer
/// entries.
/// </summary>
/// <remarks>
/// <para><b>ODBALMHIS layout (per FOCAS reference, abridged)</b>:</para>
/// <list type="bullet">
/// <item><c>short num_alm</c> — number of valid alarm-history records that follow.
/// Negative on CNC-reported error.</item>
/// <item><c>ALMHIS_data alm[N]</c> — repeated entry record. Each record carries:
/// <list type="bullet">
/// <item><c>short year, month, day, hour, minute, second</c> — wall-clock
/// time the CNC stamped on the entry. Surfaced here as
/// <see cref="DateTimeOffset"/> in UTC; the wire field is the CNC's
/// local time, but the deployment doc instructs operators to keep their
/// CNC clocks on UTC for the history projection so the dedup key stays
/// stable across DST transitions.</item>
/// <item><c>short axis_no</c> — axis the alarm relates to (1-based;
/// 0 means "no specific axis").</item>
/// <item><c>short alm_type</c> — alarm type (P/S/OT/SV/SR/MC/SP/PW/IO).
/// The numeric encoding varies slightly per series; surfaced as-is so
/// downstream consumers don't lose detail.</item>
/// <item><c>short alm_no</c> — alarm number within the type.</item>
/// <item><c>short msg_len</c> — length of the message string that follows.
/// Capped server-side at 32 chars on most series.</item>
/// <item><c>char msg[msg_len]</c> — message text. Trimmed of trailing
/// nulls + spaces before publishing.</item>
/// </list>
/// </item>
/// </list>
/// <para>The simulator-mock surface assigns command id <c>0x0F1A</c> to
/// <c>cnc_rdalmhistry</c> — see <c>docs/v2/implementation/focas-simulator-plan.md</c>.</para>
/// </remarks>
public static class FocasAlarmHistoryDecoder
{
/// <summary>Wire-protocol command identifier the simulator routes <c>cnc_rdalmhistry</c> on.</summary>
public const ushort CommandId = 0x0F1A;
/// <summary>
/// Decode a packed ODBALMHIS payload into a list of
/// <see cref="FocasAlarmHistoryEntry"/> records ordered most-recent-first (the
/// FANUC ring buffer's natural order). Returns an empty list when the buffer is
/// too small to hold the count prefix or when the CNC reported zero entries.
/// </summary>
/// <remarks>
/// <para>Layout of <paramref name="payload"/> in little-endian wire form:</para>
/// <list type="number">
/// <item>Bytes 0..1 — <c>short num_alm</c></item>
/// <item>Bytes 2..N — repeated entry blocks. Each block: 14 bytes of fixed
/// header (<c>year, month, day, hour, minute, second, axis_no, alm_type,
/// alm_no, msg_len</c> — 7×short with the seventh shared between
/// <c>axis_no</c>+packing — laid out as 10 little-endian shorts here for
/// simplicity), followed by <c>msg_len</c> ASCII bytes. The simulator pads
/// each block to a 4-byte boundary; this decoder follows.</item>
/// </list>
/// <para>Real FWLIB hands back a Marshal-shaped struct, not a packed buffer; the
/// packed-buffer convention here is purely for the simulator + IPC transport so
/// the wire protocol stays language-neutral. Tier-C Fwlib32-backed clients
/// short-circuit this decoder by surfacing the struct fields directly.</para>
/// </remarks>
public static IReadOnlyList<FocasAlarmHistoryEntry> Decode(ReadOnlySpan<byte> payload)
{
if (payload.Length < 2) return Array.Empty<FocasAlarmHistoryEntry>();
var count = BinaryPrimitives.ReadInt16LittleEndian(payload[..2]);
if (count <= 0) return Array.Empty<FocasAlarmHistoryEntry>();
var entries = new List<FocasAlarmHistoryEntry>(count);
var offset = 2;
for (var i = 0; i < count; i++)
{
// Each entry: 10 little-endian shorts of header (20 bytes) + msg_len bytes.
// Header layout: year, month, day, hour, minute, second, axis_no, alm_type,
// alm_no, msg_len.
const int headerBytes = 20;
if (offset + headerBytes > payload.Length) break;
var header = payload.Slice(offset, headerBytes);
var year = BinaryPrimitives.ReadInt16LittleEndian(header[0..2]);
var month = BinaryPrimitives.ReadInt16LittleEndian(header[2..4]);
var day = BinaryPrimitives.ReadInt16LittleEndian(header[4..6]);
var hour = BinaryPrimitives.ReadInt16LittleEndian(header[6..8]);
var minute = BinaryPrimitives.ReadInt16LittleEndian(header[8..10]);
var second = BinaryPrimitives.ReadInt16LittleEndian(header[10..12]);
var axisNo = BinaryPrimitives.ReadInt16LittleEndian(header[12..14]);
var almType = BinaryPrimitives.ReadInt16LittleEndian(header[14..16]);
var almNo = BinaryPrimitives.ReadInt16LittleEndian(header[16..18]);
var msgLen = BinaryPrimitives.ReadInt16LittleEndian(header[18..20]);
offset += headerBytes;
if (msgLen < 0 || offset + msgLen > payload.Length) break;
var msgBytes = payload.Slice(offset, msgLen);
var msg = Encoding.ASCII.GetString(msgBytes).TrimEnd('\0', ' ');
offset += msgLen;
// Pad to 4-byte boundary so per-entry blocks stay self-delimiting on the wire.
var pad = (4 - (msgLen % 4)) % 4;
offset += pad;
DateTimeOffset occurrence;
try
{
occurrence = new DateTimeOffset(
year, month, day, hour, minute, second, TimeSpan.Zero);
}
catch (ArgumentOutOfRangeException)
{
// CNC reported a malformed timestamp — skip the entry rather than
// exception-spew the entire history poll. The dedup key would be
// unstable for malformed timestamps anyway.
continue;
}
entries.Add(new FocasAlarmHistoryEntry(
OccurrenceTime: occurrence,
AxisNo: axisNo,
AlarmType: almType,
AlarmNumber: almNo,
Message: msg));
}
return entries;
}
/// <summary>
/// Encode <paramref name="entries"/> into the wire format <see cref="Decode"/>
/// consumes. Used by the simulator-mock + tests to build canned payloads without
/// having to know the byte-level layout. Output is a fresh array; callers don't
/// need to manage a pooled buffer.
/// </summary>
public static byte[] Encode(IReadOnlyList<FocasAlarmHistoryEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
// Pre-size: 2-byte count + 20-byte header + msg + pad per entry.
var size = 2;
foreach (var e in entries)
{
var msg = e.Message ?? string.Empty;
var msgBytes = Encoding.ASCII.GetByteCount(msg);
size += 20 + msgBytes + ((4 - (msgBytes % 4)) % 4);
}
var buf = new byte[size];
var span = buf.AsSpan();
BinaryPrimitives.WriteInt16LittleEndian(span[..2], (short)Math.Min(entries.Count, short.MaxValue));
var offset = 2;
foreach (var e in entries)
{
var msg = e.Message ?? string.Empty;
var t = e.OccurrenceTime.ToUniversalTime();
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 0, 2), (short)t.Year);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 2, 2), (short)t.Month);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 4, 2), (short)t.Day);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 6, 2), (short)t.Hour);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 8, 2), (short)t.Minute);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 10, 2), (short)t.Second);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 12, 2), (short)e.AxisNo);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 14, 2), (short)e.AlarmType);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 16, 2), (short)e.AlarmNumber);
var msgLen = Encoding.ASCII.GetByteCount(msg);
BinaryPrimitives.WriteInt16LittleEndian(span.Slice(offset + 18, 2), (short)msgLen);
offset += 20;
Encoding.ASCII.GetBytes(msg, span.Slice(offset, msgLen));
offset += msgLen;
offset += (4 - (msgLen % 4)) % 4;
}
return buf;
}
}