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
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user