Auto: focas-f1e — operator messages + block text

Closes #261
This commit is contained in:
Joseph Doherty
2026-04-25 14:49:11 -04:00
parent 84913638b1
commit cc757855e6
5 changed files with 507 additions and 0 deletions

View File

@@ -36,6 +36,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
new(StringComparer.OrdinalIgnoreCase); new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, (string Host, string Slot, string Axis)> _offsetNodesByName = private readonly Dictionary<string, (string Host, string Slot, string Axis)> _offsetNodesByName =
new(StringComparer.OrdinalIgnoreCase); 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); private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary> /// <summary>
@@ -185,6 +189,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
(device.Options.HostAddress, slot, axis); (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) if (_options.Probe.Enabled)
@@ -230,6 +242,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_overrideNodesByName.Clear(); _overrideNodesByName.Clear();
_toolingNodesByName.Clear(); _toolingNodesByName.Clear();
_offsetNodesByName.Clear(); _offsetNodesByName.Clear();
_messagesNodesByName.Clear();
_currentBlockNodesByName.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
} }
@@ -299,6 +313,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
continue; 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)) if (!_tagsByName.TryGetValue(reference, out var def))
{ {
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); 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; 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) => private static string OffsetReferenceFor(string hostAddress, string slot, string axis) =>
$"{hostAddress}::Offsets/{slot}/{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 private static ushort? OverrideParamFor(FocasOverrideParameters p, string field) => field switch
{ {
"Feed" => p.FeedParam, "Feed" => p.FeedParam,
@@ -700,6 +764,23 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.LastWorkOffsetsUtc = DateTime.UtcNow; 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; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
@@ -801,6 +882,32 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
device.LastWorkOffsetsUtc, now); 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) private void TransitionDeviceState(DeviceState state, HostState newState)
{ {
HostState old; HostState old;
@@ -905,6 +1012,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public FocasWorkOffsetsInfo? LastWorkOffsets { get; set; } public FocasWorkOffsetsInfo? LastWorkOffsets { get; set; }
public DateTime LastWorkOffsetsUtc { 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() public void DisposeClient()
{ {
Client?.Dispose(); Client?.Dispose();

View File

@@ -287,6 +287,52 @@ internal sealed class FwlibFocasClient : IFocasClient
return Task.FromResult<FocasWorkOffsetsInfo?>(new FocasWorkOffsetsInfo(slots)); 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> /// <summary>
/// Decode one OFSB axis block from a <c>cnc_rdzofs</c> data buffer. Each axis /// 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> + /// occupies 10 bytes per <c>fwlib32.h</c>: <c>int data</c> + <c>short dec</c> +

View File

@@ -141,6 +141,32 @@ internal static class FwlibNative
short length, short length,
ref IODBZOFS buffer); 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 ---- // ---- Structs ----
/// <summary> /// <summary>
@@ -241,6 +267,37 @@ internal static class FwlibNative
public byte[] Data; 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> /// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 1)] [StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct ODBST public struct ODBST

View File

@@ -114,6 +114,27 @@ public interface IFocasClient : IDisposable
/// </summary> /// </summary>
Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken) Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
=> Task.FromResult<FocasWorkOffsetsInfo?>(null); => 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> /// <summary>
@@ -215,6 +236,35 @@ public sealed record FocasWorkOffset(string Name, double X, double Y, double Z);
/// </summary> /// </summary>
public sealed record FocasWorkOffsetsInfo(IReadOnlyList<FocasWorkOffset> Offsets); 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> /// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
public interface IFocasClientFactory public interface IFocasClientFactory
{ {

View File

@@ -0,0 +1,231 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasMessagesBlockTextFixedTreeTests
{
private const string Host = "focas://10.0.0.7:8193";
/// <summary>
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
/// <see cref="FocasOperatorMessagesInfo"/> + <see cref="FocasCurrentBlockInfo"/>
/// snapshots for the F1-e Messages/External/Latest + Program/CurrentBlock
/// fixed-tree (issue #261).
/// </summary>
private sealed class MessagesAwareFakeFocasClient : FakeFocasClient, IFocasClient
{
public FocasOperatorMessagesInfo? Messages { get; set; }
public FocasCurrentBlockInfo? CurrentBlock { get; set; }
Task<FocasOperatorMessagesInfo?> IFocasClient.GetOperatorMessagesAsync(CancellationToken ct) =>
Task.FromResult(Messages);
Task<FocasCurrentBlockInfo?> IFocasClient.GetCurrentBlockAsync(CancellationToken ct) =>
Task.FromResult(CurrentBlock);
}
[Fact]
public async Task DiscoverAsync_emits_Messages_External_Latest_and_Program_CurrentBlock_nodes()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-msg", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Messages" && f.DisplayName == "Messages");
builder.Folders.ShouldContain(f => f.BrowseName == "External" && f.DisplayName == "External");
builder.Folders.ShouldContain(f => f.BrowseName == "Program" && f.DisplayName == "Program");
var latest = builder.Variables.SingleOrDefault(v =>
v.Info.FullName == $"{Host}::Messages/External/Latest");
latest.BrowseName.ShouldBe("Latest");
latest.Info.DriverDataType.ShouldBe(DriverDataType.String);
latest.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
var block = builder.Variables.SingleOrDefault(v =>
v.Info.FullName == $"{Host}::Program/CurrentBlock");
block.BrowseName.ShouldBe("CurrentBlock");
block.Info.DriverDataType.ShouldBe(DriverDataType.String);
block.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
[Fact]
public async Task ReadAsync_serves_Messages_Latest_and_CurrentBlock_from_cached_snapshot()
{
var fake = new MessagesAwareFakeFocasClient
{
Messages = new FocasOperatorMessagesInfo(
[
new FocasOperatorMessage(2001, "OPMSG", "TOOL CHANGE READY"),
new FocasOperatorMessage(3010, "EXTERN", "DOOR OPEN"),
]),
CurrentBlock = new FocasCurrentBlockInfo("G01 X100. Y200. F500."),
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-msg-read", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Program/CurrentBlock"], CancellationToken.None)).Single();
return snap.StatusCode == FocasStatusMapper.Good;
}, TimeSpan.FromSeconds(3));
var refs = new[]
{
$"{Host}::Messages/External/Latest",
$"{Host}::Program/CurrentBlock",
};
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
// "Latest" surfaces the last entry in the message snapshot — issue #261 permits
// this minimal "latest message" surface in lieu of full ring-buffer coverage.
snaps[0].Value.ShouldBe("DOOR OPEN");
snaps[1].Value.ShouldBe("G01 X100. Y200. F500.");
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
{
// Probe disabled — neither cache populates; the nodes still resolve as known
// references but report Bad until the first poll. Mirrors the f1a/f1b/f1c/f1d
// policy.
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-msg-empty", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(
[$"{Host}::Messages/External/Latest", $"{Host}::Program/CurrentBlock"],
CancellationToken.None);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
[Fact]
public async Task ReadAsync_publishes_empty_string_when_message_snapshot_is_empty()
{
// Empty snapshot (CNC reported no active messages) still publishes Good +
// empty string — operators distinguish "no messages" from "Bad" without
// having to read separate availability nodes.
var fake = new MessagesAwareFakeFocasClient
{
Messages = new FocasOperatorMessagesInfo([]),
CurrentBlock = new FocasCurrentBlockInfo(""),
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-msg-empty-snap", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(async () =>
{
var snap = (await drv.ReadAsync(
[$"{Host}::Messages/External/Latest"], CancellationToken.None)).Single();
return snap.StatusCode == FocasStatusMapper.Good;
}, TimeSpan.FromSeconds(3));
var snaps = await drv.ReadAsync(
[$"{Host}::Messages/External/Latest", $"{Host}::Program/CurrentBlock"],
CancellationToken.None);
snaps[0].Value.ShouldBe(string.Empty);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[1].Value.ShouldBe(string.Empty);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task FwlibFocasClient_GetOperatorMessages_and_GetCurrentBlock_return_null_when_disconnected()
{
// Construction is licence-safe (no DLL load); the unconnected client must
// short-circuit before P/Invoke. Returns null → driver leaves the cache
// untouched, matching the policy in f1a/f1b/f1c/f1d.
var client = new FwlibFocasClient();
(await client.GetOperatorMessagesAsync(CancellationToken.None)).ShouldBeNull();
(await client.GetCurrentBlockAsync(CancellationToken.None)).ShouldBeNull();
}
[Fact]
public void TrimAnsiPadding_strips_trailing_nulls_and_spaces_for_round_trip()
{
// The CNC right-pads block text + opmsg bodies with NULs or spaces; the
// managed side trims them so the same message round-trips with stable text
// (issue #261). Stops at the first NUL so reused buffers don't leak old bytes.
var buf = new byte[16];
var bytes = System.Text.Encoding.ASCII.GetBytes("G01 X10 ");
Array.Copy(bytes, buf, bytes.Length);
FwlibFocasClient.TrimAnsiPadding(buf).ShouldBe("G01 X10");
// NUL-terminated mid-buffer with trailing spaces beyond the NUL — trim stops
// at the NUL so leftover bytes in the rest of the buffer are ignored.
var buf2 = new byte[32];
var bytes2 = System.Text.Encoding.ASCII.GetBytes("OPMSG TEXT");
Array.Copy(bytes2, buf2, bytes2.Length);
// After NUL the buffer has zeros — already invisible — but explicit space
// padding before the NUL should be trimmed.
var buf3 = new byte[32];
var bytes3 = System.Text.Encoding.ASCII.GetBytes("HELLO ");
Array.Copy(bytes3, buf3, bytes3.Length);
FwlibFocasClient.TrimAnsiPadding(buf2).ShouldBe("OPMSG TEXT");
FwlibFocasClient.TrimAnsiPadding(buf3).ShouldBe("HELLO");
// Empty buffer → empty string (no exception).
FwlibFocasClient.TrimAnsiPadding(new byte[8]).ShouldBe(string.Empty);
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!await condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}