@@ -36,6 +36,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Slot, string Axis)> _offsetNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _messagesNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _currentBlockNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>
|
||||
@@ -185,6 +189,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
(device.Options.HostAddress, slot, axis);
|
||||
}
|
||||
}
|
||||
|
||||
// Messages/External/Latest + Program/CurrentBlock — single String nodes per
|
||||
// device backed by cnc_rdopmsg3 + cnc_rdactpt caches refreshed on the probe
|
||||
// tick (issue #261). Permissive across series (no capability gate yet).
|
||||
_messagesNodesByName[MessagesLatestReferenceFor(device.Options.HostAddress)] =
|
||||
device.Options.HostAddress;
|
||||
_currentBlockNodesByName[CurrentBlockReferenceFor(device.Options.HostAddress)] =
|
||||
device.Options.HostAddress;
|
||||
}
|
||||
|
||||
if (_options.Probe.Enabled)
|
||||
@@ -230,6 +242,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_overrideNodesByName.Clear();
|
||||
_toolingNodesByName.Clear();
|
||||
_offsetNodesByName.Clear();
|
||||
_messagesNodesByName.Clear();
|
||||
_currentBlockNodesByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
@@ -299,6 +313,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fixed-tree Messages/External/Latest + Program/CurrentBlock — served from
|
||||
// cnc_rdopmsg3 + cnc_rdactpt caches refreshed on the probe tick (issue #261).
|
||||
if (_messagesNodesByName.TryGetValue(reference, out var messagesHost))
|
||||
{
|
||||
results[i] = ReadMessagesLatestField(messagesHost, now);
|
||||
continue;
|
||||
}
|
||||
if (_currentBlockNodesByName.TryGetValue(reference, out var blockHost))
|
||||
{
|
||||
results[i] = ReadCurrentBlockField(blockHost, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
@@ -541,6 +568,37 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed-tree Messages/External/Latest — single String node per device backed
|
||||
// by cnc_rdopmsg3 across the four FANUC operator-message classes (issue #261).
|
||||
// The issue body permits this minimal "latest message" surface in the first
|
||||
// cut over a full ring-buffer of all four slots.
|
||||
var messagesFolder = deviceFolder.Folder("Messages", "Messages");
|
||||
var externalFolder = messagesFolder.Folder("External", "External");
|
||||
var messagesRef = MessagesLatestReferenceFor(device.HostAddress);
|
||||
externalFolder.Variable("Latest", "Latest", new DriverAttributeInfo(
|
||||
FullName: messagesRef,
|
||||
DriverDataType: DriverDataType.String,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
|
||||
// Fixed-tree Program/CurrentBlock — single String node per device backed by
|
||||
// cnc_rdactpt (issue #261). Trim-stable round-trip per the issue body.
|
||||
var programFolder = deviceFolder.Folder("Program", "Program");
|
||||
var blockRef = CurrentBlockReferenceFor(device.HostAddress);
|
||||
programFolder.Variable("CurrentBlock", "CurrentBlock", new DriverAttributeInfo(
|
||||
FullName: blockRef,
|
||||
DriverDataType: DriverDataType.String,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -563,6 +621,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private static string OffsetReferenceFor(string hostAddress, string slot, string axis) =>
|
||||
$"{hostAddress}::Offsets/{slot}/{axis}";
|
||||
|
||||
private static string MessagesLatestReferenceFor(string hostAddress) =>
|
||||
$"{hostAddress}::Messages/External/Latest";
|
||||
|
||||
private static string CurrentBlockReferenceFor(string hostAddress) =>
|
||||
$"{hostAddress}::Program/CurrentBlock";
|
||||
|
||||
private static ushort? OverrideParamFor(FocasOverrideParameters p, string field) => field switch
|
||||
{
|
||||
"Feed" => p.FeedParam,
|
||||
@@ -700,6 +764,23 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
state.LastWorkOffsetsUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
// Operator messages + currently-executing block — same best-effort
|
||||
// policy as the other fixed-tree caches (issue #261). A null result
|
||||
// leaves the previous good snapshot in place so reads keep serving
|
||||
// until the next successful refresh.
|
||||
var messages = await client.GetOperatorMessagesAsync(ct).ConfigureAwait(false);
|
||||
if (messages is not null)
|
||||
{
|
||||
state.LastMessages = messages;
|
||||
state.LastMessagesUtc = DateTime.UtcNow;
|
||||
}
|
||||
var block = await client.GetCurrentBlockAsync(ct).ConfigureAwait(false);
|
||||
if (block is not null)
|
||||
{
|
||||
state.LastCurrentBlock = block;
|
||||
state.LastCurrentBlockUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
@@ -801,6 +882,32 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
device.LastWorkOffsetsUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadMessagesLatestField(string hostAddress, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastMessages is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
// Snapshot is the trimmed list of active classes. "Latest" surfaces the last
|
||||
// (most-recent) entry — the issue body permits this minimal "latest message"
|
||||
// surface in lieu of a full ring buffer of all 4 classes.
|
||||
var latest = snap.Messages.Count == 0
|
||||
? string.Empty
|
||||
: snap.Messages[snap.Messages.Count - 1].Text;
|
||||
return new DataValueSnapshot(latest, FocasStatusMapper.Good,
|
||||
device.LastMessagesUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadCurrentBlockField(string hostAddress, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastCurrentBlock is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
return new DataValueSnapshot(snap.Text, FocasStatusMapper.Good,
|
||||
device.LastCurrentBlockUtc, now);
|
||||
}
|
||||
|
||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
@@ -905,6 +1012,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public FocasWorkOffsetsInfo? LastWorkOffsets { get; set; }
|
||||
public DateTime LastWorkOffsetsUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdopmsg3</c> snapshot — active operator messages across
|
||||
/// the four FANUC classes — refreshed on every probe tick. Reads of
|
||||
/// <c>Messages/External/Latest</c> serve from this cache (issue #261).
|
||||
/// </summary>
|
||||
public FocasOperatorMessagesInfo? LastMessages { get; set; }
|
||||
public DateTime LastMessagesUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdactpt</c> snapshot — currently-executing block text —
|
||||
/// refreshed on every probe tick. Reads of <c>Program/CurrentBlock</c>
|
||||
/// serve from this cache (issue #261).
|
||||
/// </summary>
|
||||
public FocasCurrentBlockInfo? LastCurrentBlock { get; set; }
|
||||
public DateTime LastCurrentBlockUtc { get; set; }
|
||||
|
||||
public void DisposeClient()
|
||||
{
|
||||
Client?.Dispose();
|
||||
|
||||
@@ -287,6 +287,52 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
return Task.FromResult<FocasWorkOffsetsInfo?>(new FocasWorkOffsetsInfo(slots));
|
||||
}
|
||||
|
||||
public Task<FocasOperatorMessagesInfo?> GetOperatorMessagesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasOperatorMessagesInfo?>(null);
|
||||
// type 0..3 = OPMSG / MACRO / EXTERN / REJ-EXT (issue #261). Single-slot read
|
||||
// (length 4 + 256 = 260) returns the most-recent message in each class — best-
|
||||
// effort: a single-class failure leaves that class out of the snapshot rather
|
||||
// than failing the whole call, mirroring GetProductionAsync's policy.
|
||||
var list = new List<FocasOperatorMessage>(4);
|
||||
string[] classNames = ["OPMSG", "MACRO", "EXTERN", "REJ-EXT"];
|
||||
for (short t = 0; t < 4; t++)
|
||||
{
|
||||
var buf = new FwlibNative.OPMSG3 { Data = new byte[256] };
|
||||
var ret = FwlibNative.RdOpMsg3(_handle, t, length: 4 + 256, ref buf);
|
||||
if (ret != 0) continue;
|
||||
var text = TrimAnsiPadding(buf.Data);
|
||||
if (string.IsNullOrEmpty(text)) continue;
|
||||
list.Add(new FocasOperatorMessage(buf.Datano, classNames[t], text));
|
||||
}
|
||||
return Task.FromResult<FocasOperatorMessagesInfo?>(new FocasOperatorMessagesInfo(list));
|
||||
}
|
||||
|
||||
public Task<FocasCurrentBlockInfo?> GetCurrentBlockAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasCurrentBlockInfo?>(null);
|
||||
var buf = new FwlibNative.ODBACTPT { Data = new byte[256] };
|
||||
var ret = FwlibNative.RdActPt(_handle, ref buf);
|
||||
if (ret != 0) return Task.FromResult<FocasCurrentBlockInfo?>(null);
|
||||
return Task.FromResult<FocasCurrentBlockInfo?>(
|
||||
new FocasCurrentBlockInfo(TrimAnsiPadding(buf.Data)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode + trim a Fanuc ANSI byte buffer. The CNC right-pads block text + opmsg
|
||||
/// bodies with nulls or spaces; trim them so the round-trip through the OPC UA
|
||||
/// address space stays stable (issue #261). Stops at the first NUL so any wire
|
||||
/// buffer that gets reused doesn't leak old bytes.
|
||||
/// </summary>
|
||||
internal static string TrimAnsiPadding(byte[] data)
|
||||
{
|
||||
if (data is null) return string.Empty;
|
||||
var len = 0;
|
||||
for (; len < data.Length; len++)
|
||||
if (data[len] == 0) break;
|
||||
return System.Text.Encoding.ASCII.GetString(data, 0, len).TrimEnd(' ', '\0');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode one OFSB axis block from a <c>cnc_rdzofs</c> data buffer. Each axis
|
||||
/// occupies 10 bytes per <c>fwlib32.h</c>: <c>int data</c> + <c>short dec</c> +
|
||||
|
||||
@@ -141,6 +141,32 @@ internal static class FwlibNative
|
||||
short length,
|
||||
ref IODBZOFS buffer);
|
||||
|
||||
// ---- Operator messages ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdopmsg3</c> — read FANUC operator messages by class. <paramref name="type"/>:
|
||||
/// 0 = OPMSG (op-msg ladder/macro), 1 = MACRO, 2 = EXTERN (external operator message),
|
||||
/// 3 = REJ-EXT (rejected EXTERN). <paramref name="length"/>: per <c>fwlib32.h</c> the
|
||||
/// buffer is <c>4 + 256 = 260</c> bytes per message slot — single-slot reads (length 260)
|
||||
/// return the most-recent message in that class. Issue #261, plan PR F1-e.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdopmsg3", CharSet = CharSet.Ansi, ExactSpelling = true)]
|
||||
public static extern short RdOpMsg3(
|
||||
ushort handle,
|
||||
short type,
|
||||
short length,
|
||||
ref OPMSG3 buffer);
|
||||
|
||||
// ---- Currently-executing block ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdactpt</c> — read the currently-executing program block text. The
|
||||
/// reply struct holds the program / sequence numbers + the active block as a
|
||||
/// null-padded ASCII string. Issue #261, plan PR F1-e.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdactpt", CharSet = CharSet.Ansi, ExactSpelling = true)]
|
||||
public static extern short RdActPt(ushort handle, ref ODBACTPT buffer);
|
||||
|
||||
// ---- Structs ----
|
||||
|
||||
/// <summary>
|
||||
@@ -241,6 +267,37 @@ internal static class FwlibNative
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPMSG3 — single-slot operator-message read buffer per <c>fwlib32.h</c>. Per Fanuc
|
||||
/// reference: <c>short datano</c> + <c>short type</c> + <c>char data[256]</c>. The
|
||||
/// text is null-terminated + space-padded; the managed side trims trailing nulls /
|
||||
/// spaces before publishing. Length = 4 + 256 = 260 bytes; total 256 wide enough
|
||||
/// for the longest documented operator message body (issue #261).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct OPMSG3
|
||||
{
|
||||
public short Datano;
|
||||
public short Type;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ODBACTPT — current-block read buffer per <c>fwlib32.h</c>. Per Fanuc reference:
|
||||
/// <c>long o_no</c> (currently active O-number) + <c>long n_no</c> (sequence) +
|
||||
/// <c>char data[256]</c> (active block text). The text is null-terminated +
|
||||
/// space-padded; trimmed before publishing for stable round-trip (issue #261).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBACTPT
|
||||
{
|
||||
public int ONo;
|
||||
public int NNo;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBST
|
||||
|
||||
@@ -114,6 +114,27 @@ public interface IFocasClient : IDisposable
|
||||
/// </summary>
|
||||
Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasWorkOffsetsInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the four FANUC operator-message classes via <c>cnc_rdopmsg3</c> (issue #261).
|
||||
/// The call returns up to 4 active messages per class; the driver collapses the
|
||||
/// latest non-empty message per class onto the <c>Messages/External/Latest</c>
|
||||
/// fixed-tree node — the issue body permits this minimal surface in the first cut.
|
||||
/// Trailing nulls / spaces are trimmed before publishing so the same message
|
||||
/// round-trips with stable text. Returns <c>null</c> when the wire client cannot
|
||||
/// supply the snapshot (older transport variant).
|
||||
/// </summary>
|
||||
Task<FocasOperatorMessagesInfo?> GetOperatorMessagesAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasOperatorMessagesInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the currently-executing block text via <c>cnc_rdactpt</c> (issue #261).
|
||||
/// The call returns the active block of the running program; surfaced as
|
||||
/// <c>Program/CurrentBlock</c> Float-trimmed string. Returns <c>null</c> when the
|
||||
/// wire client cannot supply the snapshot.
|
||||
/// </summary>
|
||||
Task<FocasCurrentBlockInfo?> GetCurrentBlockAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasCurrentBlockInfo?>(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -215,6 +236,35 @@ public sealed record FocasWorkOffset(string Name, double X, double Y, double Z);
|
||||
/// </summary>
|
||||
public sealed record FocasWorkOffsetsInfo(IReadOnlyList<FocasWorkOffset> Offsets);
|
||||
|
||||
/// <summary>
|
||||
/// One FANUC operator message — the <see cref="Number"/> + <see cref="Class"/>
|
||||
/// + <see cref="Text"/> tuple returned by <c>cnc_rdopmsg3</c> for a single
|
||||
/// active message slot. <see cref="Class"/> is one of <c>"OPMSG"</c> /
|
||||
/// <c>"MACRO"</c> / <c>"EXTERN"</c> / <c>"REJ-EXT"</c> per the FOCAS reference
|
||||
/// for the four message types. <see cref="Text"/> is trimmed of trailing
|
||||
/// nulls + spaces so round-trips through the OPC UA address space stay stable
|
||||
/// (issue #261).
|
||||
/// </summary>
|
||||
public sealed record FocasOperatorMessage(short Number, string Class, string Text);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of all active FANUC operator messages across the four message
|
||||
/// classes (issue #261). Surfaced under the FOCAS driver's
|
||||
/// <c>Messages/External/Latest</c> fixed-tree node — the latest non-empty
|
||||
/// message in the list is what gets published. Empty list means the CNC
|
||||
/// reported no active messages; the node publishes an empty string in that
|
||||
/// case.
|
||||
/// </summary>
|
||||
public sealed record FocasOperatorMessagesInfo(IReadOnlyList<FocasOperatorMessage> Messages);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the currently-executing program block text via
|
||||
/// <c>cnc_rdactpt</c> (issue #261). <see cref="Text"/> is trimmed of trailing
|
||||
/// nulls + spaces so the same block round-trips with stable text. Surfaced
|
||||
/// as a String node at <c>Program/CurrentBlock</c>.
|
||||
/// </summary>
|
||||
public sealed record FocasCurrentBlockInfo(string Text);
|
||||
|
||||
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||
public interface IFocasClientFactory
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user