89961af6e6
MAJOR-1: a deploy that changes a driver's config respawns it, and the fresh instance's empty told record re-sent Raise for every still-active alarm, un-acking acknowledged conditions and writing a duplicate "Activated" alerts row. NativeAlarmProjector now treats Raise on an already-active condition as a re-statement (keeps Acked; ForwardNativeAlarm skips the alerts row); only Retrigger (or inactive->active) un-acks. Sources whose upstream ack state is authoritative send Retrigger for an unacked re-statement: OpcUaClient on first sight (no prior told), Galaxy for an unacked active snapshot entry. Runtime E2E drives a FRESH FOCAS driver after a redeploy and asserts the ack survives. MAJOR-2: a failed FOCAS alarm read annotates each subscribed condition BadNoCommunication once per outage (#626 quality-only path) with one Warning (MINOR-6), lifted to Good on recovery; failed alarm reads degrade driver health with LastSuccessfulRead frozen (successful ones advance it), so an alarm-only driver trips the #527 comms-loss watchdog. MINOR-3 CLAUDE.md #630 bullet; MINOR-4 split line; MINOR-5 a non-empty cnc_rdalmmsg2 payload that fits neither stride throws; MINOR-7 value-tag + parsed-address tables are built and swapped, never cleared in place under the retained poll engine. Docs corrected (FOCAS.md, AlarmTracking.md, CLAUDE.md). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
1192 lines
63 KiB
C#
1192 lines
63 KiB
C#
using System.Buffers.Binary;
|
||
using System.Net.Sockets;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||
|
||
/// <summary>
|
||
/// Pure-managed read-only FOCAS/2 Ethernet wire client. Speaks the proprietary Fanuc
|
||
/// binary protocol on TCP:8193 directly — no P/Invoke, no <c>Fwlib64.dll</c>, no
|
||
/// out-of-process Host. One instance owns two TCP sockets for the duration of a CNC
|
||
/// session; <see cref="ConnectAsync(string, int, int, CancellationToken)"/> runs the
|
||
/// two-socket initiate handshake and a setup request, subsequent reads reuse
|
||
/// <c>socket 2</c> serialised through an internal semaphore.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para><b>Read surface.</b> Covers every FOCAS call OtOpcUa's managed driver issues:
|
||
/// sysinfo, status, axis + spindle names, the <c>cnc_rddynamic2</c> fast-poll bundle,
|
||
/// parameters (typed + raw-bytes overloads), macros, PMC ranges, alarms, operation mode,
|
||
/// executing program, block count, timers, and servo / spindle meters. Writes are
|
||
/// intentionally out of scope.</para>
|
||
/// <para><b>Concurrency.</b> Callers may issue reads concurrently from multiple threads
|
||
/// — <c>socket 2</c> is guarded by a <see cref="SemaphoreSlim"/> so at most one
|
||
/// request/response pair is in flight at a time. <see cref="ConnectAsync(string, int, int, CancellationToken)"/>
|
||
/// and <see cref="DisposeAsync"/> share a second semaphore to stop the two racing.</para>
|
||
/// <para><b>Transient failures.</b> When cancellation or a socket-level error happens
|
||
/// mid-request the client closes both sockets and throws
|
||
/// <see cref="FocasWireException"/> with <see cref="FocasWireException.IsTransient"/>
|
||
/// set — the caller must reconnect before issuing the next request. The transport is
|
||
/// left deliberately torn down rather than half-open so a truncated response never
|
||
/// desynchronises the next caller's read.</para>
|
||
/// </remarks>
|
||
public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||
{
|
||
private readonly ILogger<FocasWireClient>? _logger;
|
||
private readonly SemaphoreSlim _requestGate = new(1, 1);
|
||
private readonly SemaphoreSlim _lifetimeGate = new(1, 1);
|
||
private TcpClient? _socket1;
|
||
private TcpClient? _socket2;
|
||
private NetworkStream? _stream1;
|
||
private NetworkStream? _stream2;
|
||
private bool _connected;
|
||
private bool _disposed;
|
||
private FocasResult<WireSysInfo>? _sysInfo;
|
||
|
||
/// <summary>
|
||
/// Construct a disconnected client. Optional <paramref name="logger"/> receives
|
||
/// <c>Debug</c>-level entries per response block (command ID, RC, payload length).
|
||
/// </summary>
|
||
/// <param name="logger">Optional logger for debug-level wire protocol entries.</param>
|
||
public FocasWireClient(ILogger<FocasWireClient>? logger = null)
|
||
{
|
||
_logger = logger;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Default <c>PathId</c> applied when no per-call override is supplied. Relevant for
|
||
/// multi-path CNCs; single-path controllers leave this at the default of <c>1</c>.
|
||
/// </summary>
|
||
public ushort PathId { get; set; } = 1;
|
||
|
||
/// <summary>True when the two-socket handshake has completed and the transport is live.</summary>
|
||
public bool IsConnected => _connected;
|
||
|
||
/// <summary>
|
||
/// Open the FOCAS session using an integer-seconds timeout. Idempotent — a second
|
||
/// call while already connected is a no-op. Sub-second timeouts require the
|
||
/// <see cref="ConnectAsync(string, int, TimeSpan, CancellationToken)"/> overload.
|
||
/// </summary>
|
||
/// <param name="host">The CNC hostname or IP address.</param>
|
||
/// <param name="port">The FOCAS/2 TCP port (typically 8193).</param>
|
||
/// <param name="timeoutSeconds">Connection timeout in seconds; zero or negative disables the timeout.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the connect operation.</param>
|
||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||
public Task ConnectAsync(
|
||
string host,
|
||
int port,
|
||
int timeoutSeconds = 10,
|
||
CancellationToken cancellationToken = default)
|
||
=> ConnectCoreAsync(
|
||
host,
|
||
port,
|
||
timeoutSeconds > 0 ? TimeSpan.FromSeconds(timeoutSeconds) : null,
|
||
cancellationToken);
|
||
|
||
/// <summary>
|
||
/// Open the FOCAS session with a <see cref="TimeSpan"/> timeout. Pass
|
||
/// <see cref="TimeSpan.Zero"/> to disable the timeout entirely (rely on the caller's
|
||
/// <paramref name="cancellationToken"/> instead). Idempotent.
|
||
/// </summary>
|
||
/// <param name="host">The CNC hostname or IP address.</param>
|
||
/// <param name="port">The FOCAS/2 TCP port (typically 8193).</param>
|
||
/// <param name="timeout">Connection timeout duration; <see cref="TimeSpan.Zero"/> disables the timeout.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the connect operation.</param>
|
||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||
public Task ConnectAsync(
|
||
string host,
|
||
int port,
|
||
TimeSpan timeout,
|
||
CancellationToken cancellationToken = default)
|
||
=> ConnectCoreAsync(host, port, timeout == TimeSpan.Zero ? null : timeout, cancellationToken);
|
||
|
||
private async Task ConnectCoreAsync(
|
||
string host,
|
||
int port,
|
||
TimeSpan? timeoutValue,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
await _lifetimeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||
try
|
||
{
|
||
ThrowIfDisposed();
|
||
if (_connected) return;
|
||
|
||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||
if (timeoutValue is { } value) timeout.CancelAfter(value);
|
||
|
||
try
|
||
{
|
||
_socket1 = await ConnectSocketAsync(host, port, timeout.Token).ConfigureAwait(false);
|
||
_stream1 = _socket1.GetStream();
|
||
await SendPduAsync(_stream1, FocasWireProtocol.TypeInitiate, FocasWireProtocol.BuildInitiateBody(1), timeout.Token).ConfigureAwait(false);
|
||
await ReadExpectedPduAsync(_stream1, FocasWireProtocol.TypeInitiate, timeout.Token).ConfigureAwait(false);
|
||
|
||
_socket2 = await ConnectSocketAsync(host, port, timeout.Token).ConfigureAwait(false);
|
||
_stream2 = _socket2.GetStream();
|
||
await SendPduAsync(_stream2, FocasWireProtocol.TypeInitiate, FocasWireProtocol.BuildInitiateBody(2), timeout.Token).ConfigureAwait(false);
|
||
await ReadExpectedPduAsync(_stream2, FocasWireProtocol.TypeInitiate, timeout.Token).ConfigureAwait(false);
|
||
|
||
_connected = true;
|
||
// Cache the sysinfo payload from the setup exchange so later
|
||
// ReadSysInfoAsync calls are a lookup rather than a wire hit.
|
||
var sysInfoBlock = await SendSingleRequestAsync(timeout.Token, new RequestBlock(0x0018, PathId: PathId)).ConfigureAwait(false);
|
||
_sysInfo = ToResult(sysInfoBlock, ParseSysInfo);
|
||
// Kick the cached path/session metadata request the DLL sends
|
||
// right after initiate. The result is ignored; the CNC uses it to
|
||
// populate internal state the subsequent reads depend on.
|
||
await SendRequestAsync(timeout.Token, new RequestBlock(0x000e, 0x26f0, 0x26f0, PathId: PathId)).ConfigureAwait(false);
|
||
}
|
||
catch (Exception ex) when (IsTransientException(ex))
|
||
{
|
||
CloseTransport();
|
||
throw new FocasWireException("FOCAS wire connect failed.", ex, isTransient: true);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
_lifetimeGate.Release();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Synchronous dispose — sends the close PDU when connected and tears down both
|
||
/// sockets. Idempotent. Callers on an async context should prefer
|
||
/// <see cref="DisposeAsync"/>.
|
||
/// </summary>
|
||
public void Dispose()
|
||
{
|
||
_lifetimeGate.Wait();
|
||
try
|
||
{
|
||
if (_disposed) return;
|
||
|
||
_disposed = true;
|
||
if (_stream2 is not null && _connected)
|
||
{
|
||
try
|
||
{
|
||
SendPdu(_stream2, FocasWireProtocol.TypeClose, ReadOnlySpan<byte>.Empty);
|
||
_ = FocasWireProtocol.ReadPdu(_stream2);
|
||
}
|
||
catch
|
||
{
|
||
// Close best-effort — don't let teardown failure hide a caller's real error.
|
||
}
|
||
}
|
||
CloseTransport();
|
||
}
|
||
finally
|
||
{
|
||
_lifetimeGate.Release();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Async dispose — sends the close PDU when connected and tears down both sockets.
|
||
/// Idempotent.
|
||
/// </summary>
|
||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
await _lifetimeGate.WaitAsync(CancellationToken.None).ConfigureAwait(false);
|
||
try
|
||
{
|
||
if (_disposed) return;
|
||
|
||
_disposed = true;
|
||
if (_stream2 is not null && _connected)
|
||
{
|
||
try
|
||
{
|
||
await SendPduAsync(_stream2, FocasWireProtocol.TypeClose, ReadOnlyMemory<byte>.Empty, CancellationToken.None).ConfigureAwait(false);
|
||
await FocasWireProtocol.ReadPduAsync(_stream2, CancellationToken.None).ConfigureAwait(false);
|
||
}
|
||
catch
|
||
{
|
||
// Close best-effort — don't let teardown failure hide a caller's real error.
|
||
}
|
||
}
|
||
CloseTransport();
|
||
}
|
||
finally
|
||
{
|
||
_lifetimeGate.Release();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Read CNC identity via <c>cnc_sysinfo</c>. Cached from the connect-time exchange
|
||
/// unless a per-call <paramref name="pathId"/> override is supplied.
|
||
/// </summary>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The CNC identity/sysinfo, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public async Task<FocasResult<WireSysInfo>> ReadSysInfoAsync(
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
{
|
||
if (pathId is null && _sysInfo is { } cached) return cached;
|
||
|
||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||
return await ReadSingleAsync(0x0018, ParseSysInfo, EffectivePathId(pathId), cancellationToken: callTimeout.Token).ConfigureAwait(false);
|
||
}
|
||
|
||
/// <summary>Read CNC status bits via <c>cnc_statinfo</c> (3 command blocks aggregated into one <see cref="WireStatus"/>).</summary>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The aggregated CNC status bits, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public async Task<FocasResult<WireStatus>> ReadStatusAsync(
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
{
|
||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||
var requestPathId = EffectivePathId(pathId);
|
||
var blocks = await SendRequestAsync(
|
||
callTimeout.Token,
|
||
new RequestBlock(0x0019, PathId: requestPathId),
|
||
new RequestBlock(0x00e1, PathId: requestPathId),
|
||
new RequestBlock(0x0098, PathId: requestPathId)).ConfigureAwait(false);
|
||
|
||
var rc = AggregateRc(blocks);
|
||
if (rc != 0) return new FocasResult<WireStatus>(rc, null);
|
||
|
||
var primary = FindPayload(blocks, 0x0019);
|
||
RequireLength(primary, 14, "cnc_statinfo");
|
||
var tmModePayload = FindPayload(blocks, 0x0098);
|
||
var tmMode = tmModePayload.Length >= 2 ? ReadInt16(tmModePayload, 0) : (short)0;
|
||
|
||
return new FocasResult<WireStatus>(
|
||
rc,
|
||
new WireStatus(
|
||
Auto: ReadInt16(primary, 0),
|
||
Run: ReadInt16(primary, 2),
|
||
Motion: ReadInt16(primary, 4),
|
||
Mstb: ReadInt16(primary, 6),
|
||
Emergency: ReadInt16(primary, 8),
|
||
Alarm: ReadInt16(primary, 10),
|
||
Edit: ReadInt16(primary, 12),
|
||
TmMode: tmMode));
|
||
}
|
||
|
||
/// <summary>Read configured axis names via <c>cnc_rdaxisname</c> (command <c>0x0089</c>).</summary>
|
||
/// <param name="maxCount">Maximum number of axis records to return.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The configured axis name records, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public async Task<FocasResult<IReadOnlyList<WireAxisRecord>>> ReadAxisNamesAsync(
|
||
short maxCount = 32,
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
{
|
||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||
var block = await SendSingleRequestAsync(callTimeout.Token, new RequestBlock(0x0089, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||
return ToResult(block, payload => ReadNameRecords(payload, maxCount, (index, name) => new WireAxisRecord(index, name)));
|
||
}
|
||
|
||
/// <summary>Read configured spindle names via <c>cnc_rdspdlname</c> (command <c>0x008a</c>).</summary>
|
||
/// <param name="maxCount">Maximum number of spindle records to return.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The configured spindle name records, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public async Task<FocasResult<IReadOnlyList<WireSpindleRecord>>> ReadSpindleNamesAsync(
|
||
short maxCount = 8,
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
{
|
||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||
var block = await SendSingleRequestAsync(callTimeout.Token, new RequestBlock(0x008a, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||
return ToResult(block, payload => ReadNameRecords(payload, maxCount, (index, name) => new WireSpindleRecord(index, name)));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fast-poll bundle for one axis via <c>cnc_rddynamic2</c>. Sends 9 request blocks in
|
||
/// one PDU and aggregates the replies — alarm flags, program/sequence numbers, feed
|
||
/// and spindle actuals, plus the four-slot position quadruple.
|
||
/// </summary>
|
||
/// <param name="axis">The axis number to read from.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The fast-poll dynamic snapshot for the axis, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public async Task<FocasResult<WireDynamic>> ReadDynamic2Async(
|
||
short axis = 1,
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
{
|
||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||
var requestPathId = EffectivePathId(pathId);
|
||
var blocks = await SendRequestAsync(
|
||
callTimeout.Token,
|
||
new RequestBlock(0x001a, PathId: requestPathId),
|
||
new RequestBlock(0x001c, PathId: requestPathId),
|
||
new RequestBlock(0x001d, PathId: requestPathId),
|
||
new RequestBlock(0x0024, PathId: requestPathId),
|
||
new RequestBlock(0x0025, PathId: requestPathId),
|
||
new RequestBlock(0x0026, 4, axis, PathId: requestPathId),
|
||
new RequestBlock(0x0026, 1, axis, PathId: requestPathId),
|
||
new RequestBlock(0x0026, 6, axis, PathId: requestPathId),
|
||
new RequestBlock(0x0026, 7, axis, PathId: requestPathId)).ConfigureAwait(false);
|
||
|
||
var rc = AggregateRc(blocks);
|
||
if (rc != 0) return new FocasResult<WireDynamic>(rc, null);
|
||
|
||
return new FocasResult<WireDynamic>(rc, ParseDynamic2(blocks));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Aggregate the <c>cnc_rddynamic2</c> response blocks of one axis read into a
|
||
/// <see cref="WireDynamic"/>. The four <c>0x0026</c> blocks are taken in request order
|
||
/// (absolute / machine / relative / distance-to-go), each decoded with its own scale by
|
||
/// <see cref="ParsePositionBlock"/>.
|
||
/// </summary>
|
||
/// <param name="blocks">The response blocks of one <see cref="ReadDynamic2Async"/> request.</param>
|
||
/// <returns>The decoded fast-poll bundle.</returns>
|
||
internal static WireDynamic ParseDynamic2(IReadOnlyList<ResponseBlock> blocks)
|
||
{
|
||
var programPayload = FindPayload(blocks, 0x001c);
|
||
return new WireDynamic(
|
||
ReadFirstInt32(blocks, 0x001a),
|
||
programPayload.Length >= 4 ? ReadInt32(programPayload, 0) : 0,
|
||
programPayload.Length >= 8 ? ReadInt32(programPayload, 4) : 0,
|
||
ReadFirstInt32(blocks, 0x001d),
|
||
ReadFirstInt32(blocks, 0x0024),
|
||
ReadFirstInt32(blocks, 0x0025),
|
||
new WireAxisPosition(
|
||
ReadSelectorPosition(blocks, 0x0026, 0),
|
||
ReadSelectorPosition(blocks, 0x0026, 1),
|
||
ReadSelectorPosition(blocks, 0x0026, 2),
|
||
ReadSelectorPosition(blocks, 0x0026, 3)));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Decode one <c>cnc_rddynamic2</c> position block (0x0026). The 8-byte block is
|
||
/// self-describing — <c>[0..3] i32 raw</c>, <c>[4..5] u16 base</c>, <c>[6..7] u16 exponent</c>,
|
||
/// value = <c>raw / base^exponent</c> — pinned on a real FANUC 31i-B (every block of
|
||
/// <c>ref-dynamic2-axis1.bin</c> reports base 10 / exponent 4). A 4..7-byte block carries no
|
||
/// envelope and is returned scale-less (the driver then applies the configured
|
||
/// <c>PositionDecimalPlaces</c>); a shorter one decodes as a scale-less 0, as before.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Validity (vacant <c>0xFFFF</c> exponent, a base other than 2/10, an absurd exponent) is a
|
||
/// per-node quality decision made by <see cref="FocasPosition.IsValid"/>, not a fault here:
|
||
/// raising would let one empty slot take out the whole poll. Ported from the sister
|
||
/// ignitionoee driver v0.17.0 (<c>Dynamic2.scaledPosition</c>, Focas.Wire-014).
|
||
/// </remarks>
|
||
/// <param name="payload">The 0x0026 response-block payload.</param>
|
||
/// <returns>The position with whatever scale the block carried.</returns>
|
||
internal static FocasPosition ParsePositionBlock(byte[] payload)
|
||
{
|
||
if (payload.Length >= 8)
|
||
return new FocasPosition(
|
||
ReadInt32(payload, 0),
|
||
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(4, 2)),
|
||
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(6, 2)),
|
||
HasScale: true);
|
||
return new FocasPosition(payload.Length >= 4 ? ReadInt32(payload, 0) : 0);
|
||
}
|
||
|
||
/// <summary>Read servo-meter load percentages via <c>cnc_rdsvmeter</c> (command <c>0x0056</c>).</summary>
|
||
/// <param name="maxCount">Maximum number of servo meter records to return.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The servo-meter load percentages, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public async Task<FocasResult<IReadOnlyList<WireServoMeter>>> ReadServoMeterAsync(
|
||
short maxCount = 32,
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
{
|
||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||
var requestPathId = EffectivePathId(pathId);
|
||
var blocks = await SendRequestAsync(
|
||
callTimeout.Token,
|
||
new RequestBlock(0x0056, 1, PathId: requestPathId),
|
||
new RequestBlock(0x0089, PathId: requestPathId)).ConfigureAwait(false);
|
||
|
||
var rc = AggregateRc(blocks);
|
||
if (rc != 0) return new FocasResult<IReadOnlyList<WireServoMeter>>(rc, null);
|
||
|
||
var result = ParseServoMeters(FindPayload(blocks, 0x0056), FindPayload(blocks, 0x0089), maxCount);
|
||
return new FocasResult<IReadOnlyList<WireServoMeter>>(rc, result);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Read the per-spindle load meter via <c>cnc_rdspmeter</c> (command <c>0x0040</c>, type 0).
|
||
/// <see cref="WireSpindleMetric.Percent"/> is % of rated load (16384 = 100%).
|
||
/// </summary>
|
||
/// <param name="spindleSelector">Spindle selector; -1 selects all spindles.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The per-spindle load meters, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleLoadAsync(
|
||
short spindleSelector = -1,
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
=> ReadSpindleMetricAsync(0, spindleSelector, cancellationToken, timeout, pathId);
|
||
|
||
/// <summary>
|
||
/// Read the per-spindle motor-speed meter via <c>cnc_rdspmeter</c> (command <c>0x0040</c>,
|
||
/// type 1 — historically labelled <c>cnc_rdspmaxrpm</c>). It is NOT a maximum-RPM constant:
|
||
/// it is the live motor speed as a fraction of maximum, <see cref="WireSpindleMetric.Percent"/>
|
||
/// (a regression against <c>cnc_rddynamic2</c> spindle speed on a real 31i-B gives
|
||
/// <c>rpm = 1.220749 × meter</c>, i.e. 16384 = the spindle's 20,000 rpm maximum).
|
||
/// </summary>
|
||
/// <param name="spindleSelector">Spindle selector; -1 selects all spindles.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The per-spindle motor-speed meters, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMotorSpeedAsync(
|
||
short spindleSelector = -1,
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
=> ReadSpindleMetricAsync(1, spindleSelector, cancellationToken, timeout, pathId);
|
||
|
||
/// <summary>
|
||
/// Raw-bytes parameter read via <c>cnc_rdparam</c>. Caller marshals the returned
|
||
/// payload to the type declared in the per-series parameter catalog. <paramref name="axis"/>
|
||
/// selects an axis-scoped parameter; <c>0</c> means global.
|
||
/// </summary>
|
||
/// <param name="dataNumber">The parameter data number.</param>
|
||
/// <param name="axis">Axis selector; zero means a global parameter.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The raw parameter payload bytes, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public async Task<FocasResult<byte[]>> ReadParameterBytesAsync(
|
||
short dataNumber,
|
||
short axis = 0,
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
{
|
||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||
var secondArg = axis == 0 ? dataNumber : axis;
|
||
var block = await SendSingleRequestAsync(callTimeout.Token, new RequestBlock(0x000e, dataNumber, secondArg, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||
return ToResult(block, payload => payload);
|
||
}
|
||
|
||
/// <summary>Typed Int32 parameter read — convenience over <see cref="ReadParameterBytesAsync"/>.</summary>
|
||
/// <param name="dataNumber">The parameter data number.</param>
|
||
/// <param name="type">The FOCAS parameter type code.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The typed parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public async Task<FocasResult<WireParameter>> ReadParameterAsync(
|
||
short dataNumber,
|
||
short type = 0,
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
{
|
||
var result = await ReadParameterBytesAsync(dataNumber, cancellationToken: cancellationToken, timeout: timeout, pathId: pathId).ConfigureAwait(false);
|
||
if (!result.IsOk || result.Value is null) return new FocasResult<WireParameter>(result.Rc, null);
|
||
return new FocasResult<WireParameter>(
|
||
result.Rc,
|
||
new WireParameter(dataNumber, type, result.Value.Length >= 4 ? ReadInt32(result.Value, 0) : 0));
|
||
}
|
||
|
||
/// <summary>Typed 8-bit parameter read.</summary>
|
||
/// <param name="dataNumber">The parameter data number.</param>
|
||
/// <param name="axis">Axis selector; zero means a global parameter.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The typed 8-bit parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public async Task<FocasResult<byte>> ReadParameterByteAsync(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
|
||
{
|
||
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
|
||
return !result.IsOk || result.Value is null
|
||
? new FocasResult<byte>(result.Rc, default)
|
||
: new FocasResult<byte>(result.Rc, result.Value.Length >= 1 ? result.Value[0] : default);
|
||
}
|
||
|
||
/// <summary>Typed 16-bit parameter read.</summary>
|
||
/// <param name="dataNumber">The parameter data number.</param>
|
||
/// <param name="axis">Axis selector; zero means a global parameter.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The typed 16-bit parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public async Task<FocasResult<short>> ReadParameterInt16Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
|
||
{
|
||
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
|
||
return !result.IsOk || result.Value is null
|
||
? new FocasResult<short>(result.Rc, default)
|
||
: new FocasResult<short>(result.Rc, result.Value.Length >= 2 ? ReadInt16(result.Value, 0) : default);
|
||
}
|
||
|
||
/// <summary>Typed 32-bit parameter read.</summary>
|
||
/// <param name="dataNumber">The parameter data number.</param>
|
||
/// <param name="axis">Axis selector; zero means a global parameter.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The typed 32-bit parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public async Task<FocasResult<int>> ReadParameterInt32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
|
||
{
|
||
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
|
||
return !result.IsOk || result.Value is null
|
||
? new FocasResult<int>(result.Rc, default)
|
||
: new FocasResult<int>(result.Rc, result.Value.Length >= 4 ? ReadInt32(result.Value, 0) : default);
|
||
}
|
||
|
||
/// <summary>Typed IEEE-754 single-precision parameter read.</summary>
|
||
/// <param name="dataNumber">The parameter data number.</param>
|
||
/// <param name="axis">Axis selector; zero means a global parameter.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The typed single-precision parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public async Task<FocasResult<float>> ReadParameterFloat32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
|
||
{
|
||
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
|
||
return !result.IsOk || result.Value is null || result.Value.Length < 4
|
||
? new FocasResult<float>(result.Rc, default)
|
||
: new FocasResult<float>(result.Rc, BitConverter.Int32BitsToSingle(ReadInt32(result.Value, 0)));
|
||
}
|
||
|
||
/// <summary>Typed IEEE-754 double-precision parameter read.</summary>
|
||
/// <param name="dataNumber">The parameter data number.</param>
|
||
/// <param name="axis">Axis selector; zero means a global parameter.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The typed double-precision parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public async Task<FocasResult<double>> ReadParameterFloat64Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
|
||
{
|
||
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
|
||
return !result.IsOk || result.Value is null || result.Value.Length < 8
|
||
? new FocasResult<double>(result.Rc, default)
|
||
: new FocasResult<double>(result.Rc, BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64BigEndian(result.Value.AsSpan(0, 8))));
|
||
}
|
||
|
||
/// <summary>Read a single macro variable via <c>cnc_rdmacro</c> (command <c>0x0015</c>).</summary>
|
||
/// <param name="number">The macro variable number.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The macro variable value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public Task<FocasResult<WireMacro>> ReadMacroAsync(
|
||
short number,
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
=> ReadSingleWithTimeoutAsync(
|
||
0x0015,
|
||
payload => ParseMacro(number, payload),
|
||
cancellationToken, timeout, EffectivePathId(pathId), number, number);
|
||
|
||
/// <summary>
|
||
/// Decode a <c>cnc_rdmacro</c> (0x0015) payload. The on-wire <c>ODBM</c> record is 8
|
||
/// big-endian bytes: <c>[0..3] i32 mantissa</c>, <c>[4..5] i16</c> length field (a
|
||
/// <b>constant 10</b> on every observed response), <c>[6..7] i16 dec_val</c>
|
||
/// (<c>0xFFFF</c> = vacant).
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The middle field is the trap: it is not the decimal count but looks like a plausible one,
|
||
/// so reading the decimal at offset 4 decoded every macro on every control without ever
|
||
/// failing — just wrongly (the captured <c>#500 = 1.0</c> published <c>0.01</c>). Pinned by
|
||
/// a live Makino 31i-B capture; found and fixed by the sister ignitionoee driver
|
||
/// (Focas.Wire-013, 2026-08-05). A payload shorter than 8 bytes carries no
|
||
/// <c>dec_val</c> and is refused rather than guessed at.
|
||
/// </remarks>
|
||
/// <param name="number">The macro number the request asked for.</param>
|
||
/// <param name="payload">The response-block payload.</param>
|
||
/// <returns>The decoded macro.</returns>
|
||
/// <exception cref="FocasWireException">The payload is shorter than 8 bytes.</exception>
|
||
internal static WireMacro ParseMacro(short number, byte[] payload)
|
||
{
|
||
RequireLength(payload, 8, "cnc_rdmacro");
|
||
return new WireMacro(number, ReadInt32(payload, 0), ReadInt16(payload, 6));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Read a PMC range via <c>pmc_rdpmcrng</c>. <paramref name="area"/> is the numeric
|
||
/// address-letter code (see <see cref="FocasPmcArea"/>); <paramref name="dataType"/>
|
||
/// is the width code (see <see cref="FocasPmcDataType"/>). Payload is decoded into
|
||
/// <see cref="WirePmcRange.Values"/> — one entry per slot of the requested width.
|
||
/// </summary>
|
||
/// <param name="area">The PMC address-letter code numeric value.</param>
|
||
/// <param name="dataType">The PMC data width code numeric value.</param>
|
||
/// <param name="start">The starting address.</param>
|
||
/// <param name="end">The ending address; must be greater than or equal to start.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The decoded PMC range values, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public async Task<FocasResult<WirePmcRange>> ReadPmcRangeAsync(
|
||
short area,
|
||
short dataType,
|
||
ushort start,
|
||
ushort end,
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
{
|
||
if (end < start)
|
||
throw new ArgumentOutOfRangeException(nameof(end), "PMC end address must be greater than or equal to start.");
|
||
|
||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||
var block = await SendSingleRequestAsync(
|
||
callTimeout.Token,
|
||
new RequestBlock(0x8001, start, end, area, dataType, RequestClass: 2, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||
|
||
return ToResult(block, payload => ParsePmcRange(area, dataType, start, end, payload));
|
||
}
|
||
|
||
/// <summary>Typed overload for <see cref="ReadPmcRangeAsync(short, short, ushort, ushort, CancellationToken, TimeSpan?, ushort?)"/>.</summary>
|
||
/// <param name="area">The PMC address-letter code.</param>
|
||
/// <param name="dataType">The PMC data width code.</param>
|
||
/// <param name="start">The starting address.</param>
|
||
/// <param name="end">The ending address; must be greater than or equal to start.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The decoded PMC range values, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public Task<FocasResult<WirePmcRange>> ReadPmcRangeAsync(
|
||
FocasPmcArea area,
|
||
FocasPmcDataType dataType,
|
||
ushort start,
|
||
ushort end,
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
=> ReadPmcRangeAsync((short)area, (short)dataType, start, end, cancellationToken, timeout, pathId);
|
||
|
||
/// <summary>
|
||
/// Read active alarms via <c>cnc_rdalmmsg2</c> (command <c>0x0023</c>). Parses both
|
||
/// the 76-byte vendor <c>ODBALMMSG2_data</c> layout and the 80-byte legacy wire
|
||
/// shape so the same managed surface works across firmware revisions.
|
||
/// </summary>
|
||
/// <param name="type">Alarm type filter; -1 reads all active alarms.</param>
|
||
/// <param name="count">Maximum number of alarms to return.</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The active alarm list, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public async Task<FocasResult<IReadOnlyList<WireAlarm>>> ReadAlarmsAsync(
|
||
short type = -1,
|
||
short count = 32,
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
{
|
||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||
var block = await SendSingleRequestAsync(
|
||
callTimeout.Token,
|
||
new RequestBlock(0x0023, type, count, 2, 0x40, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||
|
||
return ToResult(block, payload => ParseAlarms(payload, count));
|
||
}
|
||
|
||
/// <summary>Read operation mode via <c>cnc_rdopmode</c>, returned as the typed <see cref="FocasOperationMode"/>.</summary>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The current operation mode, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public Task<FocasResult<FocasOperationMode>> ReadOperationModeAsync(
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
=> ReadSingleWithTimeoutAsync(
|
||
0x0057,
|
||
payload => (FocasOperationMode)(payload.Length >= 2 ? ReadInt16(payload, 0) : (short)0),
|
||
cancellationToken, timeout, EffectivePathId(pathId));
|
||
|
||
/// <summary>
|
||
/// Raw-code variant of <see cref="ReadOperationModeAsync"/> — returns the underlying
|
||
/// FOCAS <c>short</c> so callers storing the raw mode code (e.g. OtOpcUa's
|
||
/// <c>FocasProgramInfo.Mode</c> int field) don't have to cast the enum.
|
||
/// </summary>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The raw operation mode code, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public Task<FocasResult<short>> ReadOperationModeCodeAsync(
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
=> ReadSingleWithTimeoutAsync(
|
||
0x0057,
|
||
payload => payload.Length >= 2 ? ReadInt16(payload, 0) : (short)0,
|
||
cancellationToken, timeout, EffectivePathId(pathId));
|
||
|
||
/// <summary>Read the currently-executing program name + O-number via <c>cnc_exeprgname2</c> (command <c>0x00fc</c>).</summary>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The executing program name and O-number, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public Task<FocasResult<WireProgramName>> ReadExecutingProgramNameAsync(
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
=> ReadSingleWithTimeoutAsync(0x00fc, ParseProgramName, cancellationToken, timeout, EffectivePathId(pathId));
|
||
|
||
/// <summary>Read the executed block count via <c>cnc_rdblkcount</c>.</summary>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The executed block count, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public Task<FocasResult<int>> ReadBlockCountAsync(
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
=> ReadSingleWithTimeoutAsync(
|
||
0x0035,
|
||
payload => payload.Length >= 4 ? ReadInt32(payload, 0) : 0,
|
||
cancellationToken, timeout, EffectivePathId(pathId));
|
||
|
||
/// <summary>
|
||
/// Read one cumulative timer via <c>cnc_rdtimer</c>. <paramref name="type"/> selects
|
||
/// PowerOn / Operating / Cutting / Cycle per the FOCAS spec (0..3).
|
||
/// </summary>
|
||
/// <param name="type">Timer type selector (0=PowerOn, 1=Operating, 2=Cutting, 3=Cycle).</param>
|
||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||
/// <param name="timeout">Optional per-call timeout override.</param>
|
||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||
/// <returns>The cumulative timer value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||
public Task<FocasResult<WireTimer>> ReadTimerAsync(
|
||
short type,
|
||
CancellationToken cancellationToken = default,
|
||
TimeSpan? timeout = null,
|
||
ushort? pathId = null)
|
||
=> ReadSingleWithTimeoutAsync(
|
||
0x0120,
|
||
payload => ParseTimer(type, payload),
|
||
cancellationToken, timeout, EffectivePathId(pathId), type);
|
||
|
||
// ---- internal plumbing ------------------------------------------------------------
|
||
|
||
private async Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMetricAsync(
|
||
int metric, short spindleSelector, CancellationToken cancellationToken, TimeSpan? timeout, ushort? pathId)
|
||
{
|
||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||
var block = await SendSingleRequestAsync(
|
||
callTimeout.Token,
|
||
new RequestBlock(0x0040, metric, spindleSelector, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||
|
||
return ToResult(block, ParseSpindleMetrics);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Decode a <c>cnc_rdspmeter</c> (0x0040) payload: 8-byte records of
|
||
/// <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c>, <c>[+6..7] u16 exponent</c>, one per
|
||
/// slot of a fixed multi-slot buffer (slots past the spindle count are residue and decode
|
||
/// as <see cref="WireSpindleMetric.IsValid"/> = false). Every slot is returned, in wire order.
|
||
/// </summary>
|
||
/// <param name="payload">The response-block payload.</param>
|
||
/// <returns>The per-slot meters.</returns>
|
||
internal static IReadOnlyList<WireSpindleMetric> ParseSpindleMetrics(byte[] payload)
|
||
{
|
||
var values = new List<WireSpindleMetric>();
|
||
for (var offset = 0; offset + 8 <= payload.Length; offset += 8)
|
||
values.Add(new WireSpindleMetric(
|
||
(short)(values.Count + 1),
|
||
ReadInt32(payload, offset),
|
||
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(offset + 4, 2)),
|
||
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(offset + 6, 2))));
|
||
return values;
|
||
}
|
||
|
||
private async Task<FocasResult<T>> ReadSingleAsync<T>(
|
||
ushort command,
|
||
Func<byte[], T> parser,
|
||
ushort? pathId = null,
|
||
int arg1 = 0,
|
||
int arg2 = 0,
|
||
int arg3 = 0,
|
||
int arg4 = 0,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var block = await SendSingleRequestAsync(cancellationToken, new RequestBlock(command, arg1, arg2, arg3, arg4, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||
return ToResult(block, parser);
|
||
}
|
||
|
||
private async Task<FocasResult<T>> ReadSingleWithTimeoutAsync<T>(
|
||
ushort command,
|
||
Func<byte[], T> parser,
|
||
CancellationToken cancellationToken,
|
||
TimeSpan? timeout,
|
||
ushort pathId,
|
||
int arg1 = 0,
|
||
int arg2 = 0,
|
||
int arg3 = 0,
|
||
int arg4 = 0)
|
||
{
|
||
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
|
||
return await ReadSingleAsync(command, parser, pathId, arg1, arg2, arg3, arg4, callTimeout.Token).ConfigureAwait(false);
|
||
}
|
||
|
||
private async Task<ResponseBlock> SendSingleRequestAsync(CancellationToken cancellationToken, RequestBlock block)
|
||
{
|
||
var blocks = await SendRequestAsync(cancellationToken, block).ConfigureAwait(false);
|
||
return blocks.Count == 0 ? new ResponseBlock(block.Command, 0, Array.Empty<byte>()) : blocks[0];
|
||
}
|
||
|
||
private async Task<IReadOnlyList<ResponseBlock>> SendRequestAsync(CancellationToken cancellationToken, params RequestBlock[] blocks)
|
||
{
|
||
EnsureConnected();
|
||
await _requestGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||
var requestStarted = false;
|
||
try
|
||
{
|
||
var body = FocasWireProtocol.BuildRequestBody(blocks);
|
||
requestStarted = true;
|
||
await SendPduAsync(_stream2!, FocasWireProtocol.TypeData, body, cancellationToken).ConfigureAwait(false);
|
||
var response = await ReadExpectedPduAsync(_stream2!, FocasWireProtocol.TypeData, cancellationToken).ConfigureAwait(false);
|
||
var responseBlocks = FocasWireProtocol.ParseResponseBlocks(response.Body);
|
||
foreach (var block in responseBlocks)
|
||
_logger?.LogDebug("FOCAS response command=0x{Command:x4} rc={Rc} payloadLength={PayloadLength}", block.Command, block.Rc, block.Payload.Length);
|
||
return responseBlocks;
|
||
}
|
||
catch (Exception ex) when (requestStarted && IsTransientException(ex))
|
||
{
|
||
// A cancelled or failed mid-request write leaves the wire in an undefined state —
|
||
// tear the connection down so the next caller reconnects cleanly instead of
|
||
// consuming a stale response.
|
||
CloseTransport();
|
||
throw new FocasWireException("FOCAS wire request failed; connection was closed to avoid response desynchronization.", ex, isTransient: true);
|
||
}
|
||
finally
|
||
{
|
||
_requestGate.Release();
|
||
}
|
||
}
|
||
|
||
private static async Task<TcpClient> ConnectSocketAsync(string host, int port, CancellationToken cancellationToken)
|
||
{
|
||
var socket = new TcpClient { NoDelay = true };
|
||
try
|
||
{
|
||
await WithCancellation(socket.ConnectAsync(host, port), cancellationToken).ConfigureAwait(false);
|
||
return socket;
|
||
}
|
||
catch
|
||
{
|
||
socket.Dispose();
|
||
throw;
|
||
}
|
||
}
|
||
|
||
private static async Task SendPduAsync(NetworkStream stream, byte type, ReadOnlyMemory<byte> body, CancellationToken cancellationToken)
|
||
{
|
||
var pdu = FocasWireProtocol.BuildPdu(type, FocasWireProtocol.DirectionRequest, body.Span);
|
||
await stream.WriteAsync(pdu, 0, pdu.Length, cancellationToken).ConfigureAwait(false);
|
||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||
}
|
||
|
||
private static void SendPdu(NetworkStream stream, byte type, ReadOnlySpan<byte> body)
|
||
{
|
||
var pdu = FocasWireProtocol.BuildPdu(type, FocasWireProtocol.DirectionRequest, body);
|
||
stream.Write(pdu, 0, pdu.Length);
|
||
stream.Flush();
|
||
}
|
||
|
||
private void ThrowIfDisposed()
|
||
{
|
||
if (_disposed) throw new ObjectDisposedException(nameof(FocasWireClient));
|
||
}
|
||
|
||
private static async Task WithCancellation(Task task, CancellationToken cancellationToken)
|
||
{
|
||
if (!cancellationToken.CanBeCanceled)
|
||
{
|
||
await task.ConfigureAwait(false);
|
||
return;
|
||
}
|
||
|
||
var cancellation = new TaskCompletionSource<bool>();
|
||
using var registration = cancellationToken.Register(static state => ((TaskCompletionSource<bool>)state!).TrySetResult(true), cancellation);
|
||
if (task != await Task.WhenAny(task, cancellation.Task).ConfigureAwait(false))
|
||
throw new OperationCanceledException(cancellationToken);
|
||
await task.ConfigureAwait(false);
|
||
}
|
||
|
||
private static CancellationTokenSource CreateCallTimeout(CancellationToken cancellationToken, TimeSpan? timeout)
|
||
{
|
||
var source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||
if (timeout is { } value) source.CancelAfter(value);
|
||
return source;
|
||
}
|
||
|
||
private static async Task<Pdu> ReadExpectedPduAsync(NetworkStream stream, byte expectedType, CancellationToken cancellationToken)
|
||
{
|
||
var pdu = await FocasWireProtocol.ReadPduAsync(stream, cancellationToken).ConfigureAwait(false);
|
||
if (pdu.Type != expectedType || pdu.Direction != FocasWireProtocol.DirectionResponse)
|
||
throw new FocasWireException($"Unexpected FOCAS PDU type 0x{pdu.Type:x2}, direction 0x{pdu.Direction:x2}.", rc: null);
|
||
return pdu;
|
||
}
|
||
|
||
private void EnsureConnected()
|
||
{
|
||
ThrowIfDisposed();
|
||
if (!_connected || _stream2 is null)
|
||
throw new FocasWireException("FOCAS wire client is not connected.", rc: null, isTransient: true);
|
||
}
|
||
|
||
private void CloseTransport()
|
||
{
|
||
_connected = false;
|
||
_sysInfo = null;
|
||
_stream1?.Dispose();
|
||
_stream2?.Dispose();
|
||
_socket1?.Dispose();
|
||
_socket2?.Dispose();
|
||
_stream1 = null;
|
||
_stream2 = null;
|
||
_socket1 = null;
|
||
_socket2 = null;
|
||
}
|
||
|
||
private ushort EffectivePathId(ushort? pathId) => pathId ?? PathId;
|
||
|
||
private static FocasResult<T> ToResult<T>(ResponseBlock block, Func<byte[], T> parser)
|
||
=> block.Rc != 0
|
||
? new FocasResult<T>(block.Rc, default)
|
||
: new FocasResult<T>(block.Rc, parser(block.Payload));
|
||
|
||
private static short AggregateRc(IReadOnlyList<ResponseBlock> blocks)
|
||
=> blocks.FirstOrDefault(block => block.Rc != 0)?.Rc ?? 0;
|
||
|
||
/// <summary>
|
||
/// Decode a <c>cnc_rdtimer</c> (0x0120) payload into <see cref="WireTimer"/>. The 8-byte
|
||
/// data block is two 32-bit fields {minute, msec}, and they are <b>little-endian</b> on the
|
||
/// wire — unlike the big-endian block envelope and every other payload (sysinfo / dynamic /
|
||
/// macro). Validated against a live FANUC 31i-B (2026-06-25): the msec field only falls in
|
||
/// valid range (0..59999) under little-endian; big-endian decoded it as ~2.4e9. Captured
|
||
/// cutting-time payload <c>ac f2 10 00 90 a3 00 00</c> → minute=1110188, msec=41872. See
|
||
/// <c>docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md</c>.
|
||
/// </summary>
|
||
/// <param name="type">Timer type selector, carried through into the returned <see cref="WireTimer"/>.</param>
|
||
/// <param name="payload">The raw 8-byte <c>cnc_rdtimer</c> response payload.</param>
|
||
/// <returns>The decoded minute/msec timer value.</returns>
|
||
internal static WireTimer ParseTimer(short type, byte[] payload) => new(
|
||
type,
|
||
payload.Length >= 4 ? BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(0, 4)) : 0,
|
||
payload.Length >= 8 ? BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(4, 4)) : 0);
|
||
|
||
/// <summary>
|
||
/// Decode a <c>cnc_rdsvmeter</c> response into <see cref="WireServoMeter"/> records. Each
|
||
/// per-axis record is <b>8 bytes</b> — <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c>,
|
||
/// <c>[+6..7] u16 exponent</c> — the same self-describing envelope as the position blocks,
|
||
/// so the load is <c>value / base^exponent</c>, a direct percentage. (A 12-byte stride
|
||
/// misaligned after the first record; reading offset 4 as a decimal count divided every load
|
||
/// by 10^10 — both fixed against live 31i-B captures, the latter by the sister ignitionoee
|
||
/// driver, Focas.Wire-019.) Axis NAMES are not in the svmeter payload; they come from the
|
||
/// <c>cnc_rdaxisname</c> (0x0089) block requested alongside it and are correlated by index.
|
||
/// The payload is a fixed 32-slot buffer: slots past the controlled-axis count are residue
|
||
/// and decode as <see cref="WireServoMeter.IsValid"/> = false.
|
||
/// </summary>
|
||
/// <param name="svPayload">The raw <c>cnc_rdsvmeter</c> response payload.</param>
|
||
/// <param name="axisNamePayload">The raw <c>cnc_rdaxisname</c> response payload used to correlate axis names by index.</param>
|
||
/// <param name="maxCount">Maximum number of servo meter records to return.</param>
|
||
/// <returns>The decoded servo-meter records.</returns>
|
||
internal static IReadOnlyList<WireServoMeter> ParseServoMeters(byte[] svPayload, byte[] axisNamePayload, int maxCount)
|
||
{
|
||
var result = new List<WireServoMeter>();
|
||
for (var offset = 0; offset + 8 <= svPayload.Length && result.Count < maxCount; offset += 8)
|
||
{
|
||
var index = result.Count;
|
||
var nameOffset = index * 4;
|
||
var name = nameOffset + 4 <= axisNamePayload.Length
|
||
? FocasWireProtocol.ReadNameRecord(axisNamePayload.AsSpan(nameOffset, 4))
|
||
: string.Empty;
|
||
result.Add(new WireServoMeter(
|
||
(short)(index + 1),
|
||
name,
|
||
ReadInt32(svPayload, offset),
|
||
BinaryPrimitives.ReadUInt16BigEndian(svPayload.AsSpan(offset + 4, 2)), // BASE, not a decimal
|
||
BinaryPrimitives.ReadUInt16BigEndian(svPayload.AsSpan(offset + 6, 2))));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/// <summary>Byte width of one PMC slot for a FOCAS data-type code: Byte=1, Word=2, Long/Real=4, Double=8.</summary>
|
||
/// <param name="dataType">The PMC data width code.</param>
|
||
/// <returns>The byte width of one slot for the given data-type code.</returns>
|
||
internal static int PmcByteWidth(short dataType) => dataType switch
|
||
{
|
||
1 => 2,
|
||
2 or 4 => 4,
|
||
5 => 8,
|
||
_ => 1,
|
||
};
|
||
|
||
/// <summary>
|
||
/// Decode a <c>pmc_rdpmcrng</c> payload into <see cref="WirePmcRange"/>. The CNC returns
|
||
/// (end-start+1) BYTES; this slices them into width-sized big-endian slots. Callers must
|
||
/// size the request range to <c>width</c> bytes per value (see
|
||
/// <c>WireFocasClient.ReadPmcAsync</c>) or a trailing partial slot is dropped — which on the
|
||
/// 31i-B surfaced as a spurious <c>BadOutOfRange</c> for a single Word read. 2026-06-25.
|
||
/// </summary>
|
||
/// <param name="area">The PMC address-letter code numeric value.</param>
|
||
/// <param name="dataType">The PMC data width code numeric value.</param>
|
||
/// <param name="start">The starting address.</param>
|
||
/// <param name="end">The ending address.</param>
|
||
/// <param name="payload">The raw <c>pmc_rdpmcrng</c> response payload.</param>
|
||
/// <returns>The decoded PMC range values.</returns>
|
||
internal static WirePmcRange ParsePmcRange(short area, short dataType, ushort start, ushort end, byte[] payload)
|
||
{
|
||
var width = PmcByteWidth(dataType);
|
||
var values = new List<long>();
|
||
for (var offset = 0; offset + width <= payload.Length; offset += width)
|
||
{
|
||
values.Add(width switch
|
||
{
|
||
1 => payload[offset],
|
||
2 => ReadInt16(payload, offset),
|
||
4 => ReadInt32(payload, offset),
|
||
8 => BinaryPrimitives.ReadInt64BigEndian(payload.AsSpan(offset, 8)),
|
||
_ => 0,
|
||
});
|
||
}
|
||
return new WirePmcRange(area, dataType, start, end, values);
|
||
}
|
||
|
||
private static byte[] FindPayload(IReadOnlyList<ResponseBlock> blocks, ushort command)
|
||
=> blocks.FirstOrDefault(block => block.Command == command)?.Payload ?? Array.Empty<byte>();
|
||
|
||
private static int ReadFirstInt32(IReadOnlyList<ResponseBlock> blocks, ushort command)
|
||
{
|
||
var payload = FindPayload(blocks, command);
|
||
return payload.Length >= 4 ? ReadInt32(payload, 0) : 0;
|
||
}
|
||
|
||
private static FocasPosition ReadSelectorPosition(IReadOnlyList<ResponseBlock> blocks, ushort command, int selectorIndex)
|
||
{
|
||
var seen = 0;
|
||
foreach (var block in blocks)
|
||
{
|
||
if (block.Command != command) continue;
|
||
if (seen == selectorIndex)
|
||
return ParsePositionBlock(block.Payload);
|
||
seen++;
|
||
}
|
||
return new FocasPosition(0);
|
||
}
|
||
|
||
private static WireSysInfo ParseSysInfo(byte[] payload)
|
||
{
|
||
RequireLength(payload, 16, "cnc_sysinfo");
|
||
return new WireSysInfo(
|
||
ReadInt16(payload, 0),
|
||
ReadInt16(payload, 2),
|
||
FocasWireProtocol.ReadAscii(payload.AsSpan(4, 2)),
|
||
FocasWireProtocol.ReadAscii(payload.AsSpan(6, 2)),
|
||
FocasWireProtocol.ReadAscii(payload.AsSpan(8, 4)),
|
||
FocasWireProtocol.ReadAscii(payload.AsSpan(12, 4)),
|
||
payload.Length >= 18 ? FocasWireProtocol.ReadAscii(payload.AsSpan(16, 2)) : string.Empty);
|
||
}
|
||
|
||
private static WireProgramName ParseProgramName(byte[] payload)
|
||
{
|
||
var nameLength = payload.Length >= 40 ? 36 : payload.Length;
|
||
var name = FocasWireProtocol.ReadAscii(payload.AsSpan(0, nameLength));
|
||
var number = payload.Length >= 40 ? ReadInt32(payload, 36) : (int?)null;
|
||
return new WireProgramName(name, number);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Decode a <c>cnc_rdalmmsg2</c> payload: the 76-byte vendor stride, else the 80-byte legacy one. An empty
|
||
/// payload is "no alarms active". A non-empty payload that fits NEITHER stride is malformed and throws
|
||
/// (Gitea #647 review): it used to fall into the legacy parser, decode zero records, and so read as "no
|
||
/// alarms" — a mass Clear of every condition on the device, then an un-acknowledged re-raise next poll.
|
||
/// </summary>
|
||
/// <param name="payload">The reply payload.</param>
|
||
/// <param name="count">The requested record count.</param>
|
||
/// <returns>The decoded alarms.</returns>
|
||
/// <exception cref="FocasWireException">The payload length matches neither record layout.</exception>
|
||
internal static IReadOnlyList<WireAlarm> ParseAlarms(byte[] payload, short count)
|
||
{
|
||
if (payload.Length % 76 == 0) return ParseVendorAlarms(payload, count);
|
||
if (payload.Length % 80 == 0) return ParseLegacyWireAlarms(payload, count);
|
||
throw new FocasWireException(
|
||
$"cnc_rdalmmsg2 returned a {payload.Length}-byte payload, which fits neither the 76- nor the 80-byte " +
|
||
"record layout; refusing to read it as an empty alarm list.", rc: null);
|
||
}
|
||
|
||
private static IReadOnlyList<WireAlarm> ParseVendorAlarms(byte[] payload, short count)
|
||
{
|
||
var alarms = new List<WireAlarm>();
|
||
for (var offset = 0; offset + 76 <= payload.Length && alarms.Count < count; offset += 76)
|
||
{
|
||
var messageLength = ReadInt16(payload, offset + 10);
|
||
alarms.Add(new WireAlarm(
|
||
ReadInt32(payload, offset),
|
||
ReadInt16(payload, offset + 4),
|
||
ReadInt16(payload, offset + 6),
|
||
messageLength,
|
||
FocasWireProtocol.ReadAscii(payload.AsSpan(offset + 12, 64))));
|
||
}
|
||
return alarms;
|
||
}
|
||
|
||
private static IReadOnlyList<WireAlarm> ParseLegacyWireAlarms(byte[] payload, short count)
|
||
{
|
||
var alarms = new List<WireAlarm>();
|
||
for (var offset = 0; offset + 80 <= payload.Length && alarms.Count < count; offset += 80)
|
||
{
|
||
alarms.Add(new WireAlarm(
|
||
ReadInt32(payload, offset),
|
||
(short)ReadInt32(payload, offset + 4),
|
||
(short)ReadInt32(payload, offset + 8),
|
||
(short)ReadInt32(payload, offset + 12),
|
||
FocasWireProtocol.ReadAscii(payload.AsSpan(offset + 16, 64))));
|
||
}
|
||
return alarms;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Decode an axis-name (0x0089) or spindle-name (0x008A) payload: an array of 4-byte records,
|
||
/// each named by <see cref="FocasWireProtocol.ReadNameRecord"/>. Blank records are skipped but
|
||
/// keep their slot's 1-based index.
|
||
/// </summary>
|
||
/// <typeparam name="T">The record type the factory builds.</typeparam>
|
||
/// <param name="payload">The response-block payload.</param>
|
||
/// <param name="maxCount">Maximum number of records to inspect.</param>
|
||
/// <param name="factory">Builds one record from its 1-based index and name.</param>
|
||
/// <returns>The non-blank records, in wire order.</returns>
|
||
internal static IReadOnlyList<T> ReadNameRecords<T>(byte[] payload, short maxCount, Func<short, string, T> factory)
|
||
{
|
||
var names = new List<T>();
|
||
for (var offset = 0; offset + 4 <= payload.Length && offset / 4 < maxCount; offset += 4)
|
||
{
|
||
var name = FocasWireProtocol.ReadNameRecord(payload.AsSpan(offset, 4));
|
||
if (!string.IsNullOrWhiteSpace(name))
|
||
names.Add(factory((short)((offset / 4) + 1), name));
|
||
}
|
||
return names;
|
||
}
|
||
|
||
private static void RequireLength(byte[] payload, int length, string call)
|
||
{
|
||
if (payload.Length < length)
|
||
throw new FocasWireException($"{call} returned {payload.Length} bytes; expected at least {length}.", rc: null);
|
||
}
|
||
|
||
private static bool IsTransientException(Exception exception)
|
||
=> exception is IOException or SocketException or TimeoutException or OperationCanceledException
|
||
|| exception.InnerException is IOException or SocketException or TimeoutException or OperationCanceledException;
|
||
|
||
private static short ReadInt16(byte[] bytes, int offset)
|
||
=> BinaryPrimitives.ReadInt16BigEndian(bytes.AsSpan(offset, 2));
|
||
|
||
private static int ReadInt32(byte[] bytes, int offset)
|
||
=> BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(offset, 4));
|
||
}
|