From cc757855e61c4b35639f0adddbf4ce99446bba65 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 14:49:11 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20focas-f1e=20=E2=80=94=20operator=20mess?= =?UTF-8?q?ages=20+=20block=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #261 --- .../FocasDriver.cs | 123 ++++++++++ .../FwlibFocasClient.cs | 46 ++++ .../FwlibNative.cs | 57 +++++ .../IFocasClient.cs | 50 ++++ .../FocasMessagesBlockTextFixedTreeTests.cs | 231 ++++++++++++++++++ 5 files changed, 507 insertions(+) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMessagesBlockTextFixedTreeTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 3d3dbb7..d11a751 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -36,6 +36,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _offsetNodesByName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _messagesNodesByName = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _currentBlockNodesByName = + new(StringComparer.OrdinalIgnoreCase); private DriverHealth _health = new(DriverState.Unknown, null, null); /// @@ -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; } + /// + /// Cached cnc_rdopmsg3 snapshot — active operator messages across + /// the four FANUC classes — refreshed on every probe tick. Reads of + /// Messages/External/Latest serve from this cache (issue #261). + /// + public FocasOperatorMessagesInfo? LastMessages { get; set; } + public DateTime LastMessagesUtc { get; set; } + + /// + /// Cached cnc_rdactpt snapshot — currently-executing block text — + /// refreshed on every probe tick. Reads of Program/CurrentBlock + /// serve from this cache (issue #261). + /// + public FocasCurrentBlockInfo? LastCurrentBlock { get; set; } + public DateTime LastCurrentBlockUtc { get; set; } + public void DisposeClient() { Client?.Dispose(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs index a125c71..7b074b7 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs @@ -287,6 +287,52 @@ internal sealed class FwlibFocasClient : IFocasClient return Task.FromResult(new FocasWorkOffsetsInfo(slots)); } + public Task GetOperatorMessagesAsync(CancellationToken cancellationToken) + { + if (!_connected) return Task.FromResult(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(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(new FocasOperatorMessagesInfo(list)); + } + + public Task GetCurrentBlockAsync(CancellationToken cancellationToken) + { + if (!_connected) return Task.FromResult(null); + var buf = new FwlibNative.ODBACTPT { Data = new byte[256] }; + var ret = FwlibNative.RdActPt(_handle, ref buf); + if (ret != 0) return Task.FromResult(null); + return Task.FromResult( + new FocasCurrentBlockInfo(TrimAnsiPadding(buf.Data))); + } + + /// + /// 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. + /// + 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'); + } + /// /// Decode one OFSB axis block from a cnc_rdzofs data buffer. Each axis /// occupies 10 bytes per fwlib32.h: int data + short dec + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs index 2eb9e93..4ecfef8 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs @@ -141,6 +141,32 @@ internal static class FwlibNative short length, ref IODBZOFS buffer); + // ---- Operator messages ---- + + /// + /// cnc_rdopmsg3 — read FANUC operator messages by class. : + /// 0 = OPMSG (op-msg ladder/macro), 1 = MACRO, 2 = EXTERN (external operator message), + /// 3 = REJ-EXT (rejected EXTERN). : per fwlib32.h the + /// buffer is 4 + 256 = 260 bytes per message slot — single-slot reads (length 260) + /// return the most-recent message in that class. Issue #261, plan PR F1-e. + /// + [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 ---- + + /// + /// cnc_rdactpt — 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. + /// + [DllImport(Library, EntryPoint = "cnc_rdactpt", CharSet = CharSet.Ansi, ExactSpelling = true)] + public static extern short RdActPt(ushort handle, ref ODBACTPT buffer); + // ---- Structs ---- /// @@ -241,6 +267,37 @@ internal static class FwlibNative public byte[] Data; } + /// + /// OPMSG3 — single-slot operator-message read buffer per fwlib32.h. Per Fanuc + /// reference: short datano + short type + char data[256]. 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). + /// + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct OPMSG3 + { + public short Datano; + public short Type; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)] + public byte[] Data; + } + + /// + /// ODBACTPT — current-block read buffer per fwlib32.h. Per Fanuc reference: + /// long o_no (currently active O-number) + long n_no (sequence) + + /// char data[256] (active block text). The text is null-terminated + + /// space-padded; trimmed before publishing for stable round-trip (issue #261). + /// + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct ODBACTPT + { + public int ONo; + public int NNo; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)] + public byte[] Data; + } + /// ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode. [StructLayout(LayoutKind.Sequential, Pack = 1)] public struct ODBST diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs index 0a3d555..dfdccc8 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -114,6 +114,27 @@ public interface IFocasClient : IDisposable /// Task GetWorkOffsetsAsync(CancellationToken cancellationToken) => Task.FromResult(null); + + /// + /// Read the four FANUC operator-message classes via cnc_rdopmsg3 (issue #261). + /// The call returns up to 4 active messages per class; the driver collapses the + /// latest non-empty message per class onto the Messages/External/Latest + /// 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 null when the wire client cannot + /// supply the snapshot (older transport variant). + /// + Task GetOperatorMessagesAsync(CancellationToken cancellationToken) + => Task.FromResult(null); + + /// + /// Read the currently-executing block text via cnc_rdactpt (issue #261). + /// The call returns the active block of the running program; surfaced as + /// Program/CurrentBlock Float-trimmed string. Returns null when the + /// wire client cannot supply the snapshot. + /// + Task GetCurrentBlockAsync(CancellationToken cancellationToken) + => Task.FromResult(null); } /// @@ -215,6 +236,35 @@ public sealed record FocasWorkOffset(string Name, double X, double Y, double Z); /// public sealed record FocasWorkOffsetsInfo(IReadOnlyList Offsets); +/// +/// One FANUC operator message — the + +/// + tuple returned by cnc_rdopmsg3 for a single +/// active message slot. is one of "OPMSG" / +/// "MACRO" / "EXTERN" / "REJ-EXT" per the FOCAS reference +/// for the four message types. is trimmed of trailing +/// nulls + spaces so round-trips through the OPC UA address space stay stable +/// (issue #261). +/// +public sealed record FocasOperatorMessage(short Number, string Class, string Text); + +/// +/// Snapshot of all active FANUC operator messages across the four message +/// classes (issue #261). Surfaced under the FOCAS driver's +/// Messages/External/Latest 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. +/// +public sealed record FocasOperatorMessagesInfo(IReadOnlyList Messages); + +/// +/// Snapshot of the currently-executing program block text via +/// cnc_rdactpt (issue #261). is trimmed of trailing +/// nulls + spaces so the same block round-trips with stable text. Surfaced +/// as a String node at Program/CurrentBlock. +/// +public sealed record FocasCurrentBlockInfo(string Text); + /// Factory for s. One client per configured device. public interface IFocasClientFactory { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMessagesBlockTextFixedTreeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMessagesBlockTextFixedTreeTests.cs new file mode 100644 index 0000000..8074f44 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMessagesBlockTextFixedTreeTests.cs @@ -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"; + + /// + /// Variant of that returns configurable + /// + + /// snapshots for the F1-e Messages/External/Latest + Program/CurrentBlock + /// fixed-tree (issue #261). + /// + private sealed class MessagesAwareFakeFocasClient : FakeFocasClient, IFocasClient + { + public FocasOperatorMessagesInfo? Messages { get; set; } + public FocasCurrentBlockInfo? CurrentBlock { get; set; } + + Task IFocasClient.GetOperatorMessagesAsync(CancellationToken ct) => + Task.FromResult(Messages); + + Task 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> 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) { } } + } +}