using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
///
/// implementation backed by the in-tree managed
/// . No P/Invoke, no Fwlib64.dll, no out-of-process
/// Host — the wire client dials the CNC on TCP:8193 directly and speaks the FOCAS/2
/// Ethernet binary protocol.
///
///
/// OtOpcUa is read-only against FOCAS. returns
/// for every address — the managed wire
/// client intentionally does not expose cnc_wrparam / pmc_wrpmcrng /
/// cnc_wrmacro.
///
public sealed class WireFocasClient : IFocasClient
{
private readonly FocasWireClient _wire;
private FocasHostAddress? _address;
///
/// Default constructor — wire client without logger. Selected by the legacy
/// no-arg path.
///
public WireFocasClient() : this(logger: null) { }
///
/// Construct with an optional logger. Threaded through to
/// so the per-response Debug entries actually reach
/// the host's logging pipeline (Driver.FOCAS-007).
///
/// Optional logger for debug output from wire client responses.
public WireFocasClient(ILogger? logger)
{
_wire = new FocasWireClient(logger);
}
/// Gets a value indicating whether the wire client is connected to the FOCAS host.
public bool IsConnected => _wire.IsConnected;
/// Connects to a FOCAS host at the specified address.
/// The host address containing the machine name and port.
/// The connection timeout; values less than or equal to zero are clamped to 1 second.
/// Cancellation token for the operation.
public async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
{
if (_wire.IsConnected) return;
_address = address;
// FocasWireClient.ConnectAsync interprets TimeSpan.Zero as "no timeout" — clamp the
// driver's default TimeSpan to at least 1s so a caller passing TimeSpan.Zero gets a
// sane fail-fast instead of hanging indefinitely.
var effective = timeout <= TimeSpan.Zero ? TimeSpan.FromSeconds(1) : timeout;
await _wire.ConnectAsync(address.Host, address.Port, effective, cancellationToken).ConfigureAwait(false);
}
/// Reads a value from the specified FOCAS address.
/// The FOCAS address to read from.
/// The FOCAS data type of the value.
/// Cancellation token for the operation.
/// A tuple containing the read value and FOCAS status code.
public async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
{
if (!_wire.IsConnected) return (null, FocasStatusMapper.BadCommunicationError);
cancellationToken.ThrowIfCancellationRequested();
return address.Kind switch
{
FocasAreaKind.Pmc => await ReadPmcAsync(address, type, cancellationToken).ConfigureAwait(false),
FocasAreaKind.Parameter => await ReadParameterAsync(address, type, cancellationToken).ConfigureAwait(false),
FocasAreaKind.Macro => await ReadMacroAsync(address, cancellationToken).ConfigureAwait(false),
_ => (null, FocasStatusMapper.BadNotSupported),
};
}
/// Writes a value to a FOCAS address (always returns BadNotWritable as OtOpcUa is read-only).
/// The FOCAS address to write to.
/// The FOCAS data type of the value.
/// The value to write.
/// Cancellation token for the operation.
/// A task that returns the BadNotWritable status code.
public Task WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
=> Task.FromResult(FocasStatusMapper.BadNotWritable);
/// Probes the FOCAS host to verify connectivity.
/// Cancellation token for the operation.
/// True if the probe succeeds; otherwise false.
public async Task ProbeAsync(CancellationToken cancellationToken)
{
if (!_wire.IsConnected) return false;
try
{
var result = await _wire.ReadStatusAsync(cancellationToken).ConfigureAwait(false);
return result.IsOk;
}
catch (FocasWireException)
{
return false;
}
}
/// Reads all active alarms from the FOCAS host.
/// Cancellation token for the operation.
/// A list of active alarms; empty if read fails or not connected.
public async Task> ReadAlarmsAsync(CancellationToken cancellationToken)
{
if (!_wire.IsConnected) return [];
try
{
var result = await _wire.ReadAlarmsAsync(FocasAlarmType.All, 32, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return [];
return result.Value.Select(Map).ToList();
}
catch (FocasWireException)
{
return [];
}
static FocasActiveAlarm Map(WireAlarm a) => new(
AlarmNumber: a.AlarmNumber,
Type: a.Type,
Axis: a.Axis,
Message: a.Message ?? string.Empty);
}
/// Gets system information from the FOCAS host.
/// Cancellation token for the operation.
/// The FOCAS system information.
public async Task GetSysInfoAsync(CancellationToken cancellationToken)
{
RequireConnected();
var result = await _wire.ReadSysInfoAsync(cancellationToken).ConfigureAwait(false);
ThrowIfRcNonZero(result.Rc, "cnc_sysinfo", result.IsOk);
var info = result.Value!;
// Fanuc right-pads the ASCII axis count with spaces; fall back to MaxAxis if the
// text field isn't interpretable as an integer.
var axesCount = int.TryParse(info.Axes?.Trim(), out var parsed) ? parsed : info.MaxAxis;
return new FocasSysInfo(
AddInfo: info.AddInfo,
MaxAxis: info.MaxAxis,
CncType: info.CncType ?? string.Empty,
MtType: info.MachineType ?? string.Empty,
Series: info.Series ?? string.Empty,
Version: info.Version ?? string.Empty,
AxesCount: axesCount);
}
/// Gets the names of all axes on the FOCAS host.
/// Cancellation token for the operation.
/// A list of axis names; empty if read fails or not connected.
public async Task> GetAxisNamesAsync(CancellationToken cancellationToken)
{
if (!_wire.IsConnected) return [];
var result = await _wire.ReadAxisNamesAsync(32, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return [];
return result.Value.Select(SplitAxis).Where(n => n.Name.Length > 0).ToList();
// FocasWireClient returns axis records as a single Name string (e.g. "X" or "X1").
// IFocasClient wants Name + Suffix split — the first char is the axis letter, the
// rest is the multi-channel suffix.
static FocasAxisName SplitAxis(WireAxisRecord r)
{
var n = r.Name ?? string.Empty;
return n.Length == 0
? new FocasAxisName(string.Empty, string.Empty)
: new FocasAxisName(n[..1], n.Length > 1 ? n[1..] : string.Empty);
}
}
/// Gets the names of all spindles on the FOCAS host.
/// Cancellation token for the operation.
/// A list of spindle names; empty if read fails or not connected.
public async Task> GetSpindleNamesAsync(CancellationToken cancellationToken)
{
if (!_wire.IsConnected) return [];
var result = await _wire.ReadSpindleNamesAsync(8, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return [];
return result.Value.Select(SplitSpindle).Where(n => n.Name.Length > 0).ToList();
static FocasSpindleName SplitSpindle(WireSpindleRecord r)
{
var n = r.Name ?? string.Empty;
return new FocasSpindleName(
Name: n.Length > 0 ? n[..1] : string.Empty,
Suffix1: n.Length > 1 ? n[1..2] : string.Empty,
Suffix2: n.Length > 2 ? n[2..3] : string.Empty,
Suffix3: n.Length > 3 ? n[3..4] : string.Empty);
}
}
/// Reads the dynamic state of a specified axis.
/// The index of the axis to read.
/// Cancellation token for the operation.
/// The dynamic snapshot of the axis.
public async Task ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken)
{
RequireConnected();
var result = await _wire.ReadDynamic2Async((short)axisIndex, cancellationToken).ConfigureAwait(false);
ThrowIfRcNonZero(result.Rc, "cnc_rddynamic2", result.IsOk);
var d = result.Value!;
var pos = d.Axis ?? new WireAxisPosition(0, 0, 0, 0);
return new FocasDynamicSnapshot(
AxisIndex: axisIndex,
AlarmFlags: d.Alarm,
ProgramNumber: d.ProgramNumber,
MainProgramNumber: d.MainProgramNumber,
SequenceNumber: d.SequenceNumber,
ActualFeedRate: d.FeedRate,
ActualSpindleSpeed: d.SpindleSpeed,
AbsolutePosition: pos.Absolute,
MachinePosition: pos.Machine,
RelativePosition: pos.Relative,
DistanceToGo: pos.Distance);
}
/// Gets information about the currently executing program.
/// Cancellation token for the operation.
/// The current program information.
public async Task GetProgramInfoAsync(CancellationToken cancellationToken)
{
RequireConnected();
var nameResult = await _wire.ReadExecutingProgramNameAsync(cancellationToken).ConfigureAwait(false);
var blkResult = await _wire.ReadBlockCountAsync(cancellationToken).ConfigureAwait(false);
// Use the raw short variant — FocasProgramInfo.Mode stores the integer code so the
// managed ToText path in FocasOpMode can map it for display.
var modeResult = await _wire.ReadOperationModeCodeAsync(cancellationToken).ConfigureAwait(false);
// The fixed-tree bootstrap probe (FocasDriver.SafeTryProbe) classifies the
// ProgramInfo capability as "supported" iff this method returns non-null. A CNC
// series without cnc_exeprgname2 / cnc_rdopmode answers EW_FUNC / EW_NOOPT, so
// throw when neither the program-name nor the op-mode read succeeded — otherwise
// SafeTryProbe records a false-positive capability and the driver emits Program/
// OperationMode/ subtrees that only ever return BadDeviceFailure.
if (!nameResult.IsOk && !modeResult.IsOk)
throw new InvalidOperationException(
$"cnc_exeprgname2 failed EW_{nameResult.Rc} and cnc_rdopmode failed EW_{modeResult.Rc}.");
var wireName = nameResult.Value;
return new FocasProgramInfo(
Name: wireName?.Name ?? string.Empty,
ONumber: wireName?.ONumber ?? 0,
BlockCount: blkResult.IsOk ? blkResult.Value : 0,
Mode: modeResult.IsOk ? modeResult.Value : 0);
}
/// Gets a timer value from the FOCAS host.
/// The kind of timer to read (run time, cutting time, etc.).
/// Cancellation token for the operation.
/// The timer value.
public async Task GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken)
{
RequireConnected();
var result = await _wire.ReadTimerAsync((short)kind, cancellationToken).ConfigureAwait(false);
ThrowIfRcNonZero(result.Rc, $"cnc_rdtimer kind={kind}", result.IsOk);
var t = result.Value!;
return new FocasTimer(kind, t.Minutes, t.Milliseconds);
}
/// Gets servo load information for all axes.
/// Cancellation token for the operation.
/// A list of servo load values for each axis; empty if read fails or not connected.
public async Task> GetServoLoadsAsync(CancellationToken cancellationToken)
{
if (!_wire.IsConnected) return [];
var result = await _wire.ReadServoMeterAsync(32, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return [];
return result.Value
.Select(m => new FocasServoLoad(m.Name ?? string.Empty, m.Value / Math.Pow(10.0, m.Decimal)))
.Where(s => s.AxisName.Length > 0)
.ToList();
}
/// Gets spindle load information for all spindles.
/// Cancellation token for the operation.
/// A list of spindle load percentages; empty if read fails or not connected.
public Task> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken);
/// Gets maximum RPM information for all spindles.
/// Cancellation token for the operation.
/// A list of maximum RPM values for each spindle; empty if read fails or not connected.
public Task> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken);
private static async Task> ReadSpindleMetricAsync(
Func>>> call,
CancellationToken cancellationToken)
{
var result = await call(-1, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return [];
var list = new List();
foreach (var m in result.Value)
{
// Fanuc pads unused spindle slots with 0 — stop at the first trailing zero so the
// list length matches the configured spindle count.
if (m.Value == 0 && list.Count > 0) break;
list.Add(m.Value);
}
return list;
}
/// Disposes the wire client and releases all resources.
public void Dispose() => _wire.Dispose();
// ---- PMC / Parameter / Macro read paths ------------------------------------------
private async Task<(object? value, uint status)> ReadPmcAsync(
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
{
var area = FocasPmcAreaLookup.FromLetter(address.PmcLetter ?? string.Empty);
if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown);
var dataType = FocasPmcDataTypeLookup.FromFocasDataType(type);
var start = (ushort)address.Number;
var end = start;
try
{
var result = await _wire.ReadPmcRangeAsync(area.Value, dataType, start, end, cancellationToken)
.ConfigureAwait(false);
if (!result.IsOk || result.Value is null)
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
var values = result.Value.Values;
if (values.Count == 0) return (null, FocasStatusMapper.BadOutOfRange);
var raw = values[0];
var mapped = type switch
{
FocasDataType.Bit => (object)(((long)raw >> (address.BitIndex ?? 0) & 1L) != 0),
FocasDataType.Byte => (object)(sbyte)(raw & 0xFFL),
FocasDataType.Int16 => (object)(short)raw,
FocasDataType.Int32 => (object)(int)raw,
FocasDataType.Float32 => (object)BitConverter.Int32BitsToSingle((int)raw),
FocasDataType.Float64 => (object)BitConverter.Int64BitsToDouble(raw),
_ => (object)raw,
};
return (mapped, FocasStatusMapper.Good);
}
catch (FocasWireException ex)
{
return (null, ex.Rc is short rc ? FocasStatusMapper.MapFocasReturn(rc) : FocasStatusMapper.BadCommunicationError);
}
}
private async Task<(object? value, uint status)> ReadParameterAsync(
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
{
try
{
switch (type)
{
case FocasDataType.Byte:
var b = await _wire.ReadParameterByteAsync((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
return b.IsOk ? ((object)(sbyte)b.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(b.Rc));
case FocasDataType.Int16:
var s = await _wire.ReadParameterInt16Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
return s.IsOk ? ((object)s.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(s.Rc));
case FocasDataType.Float32:
var f = await _wire.ReadParameterFloat32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
return f.IsOk ? ((object)f.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(f.Rc));
case FocasDataType.Float64:
var d = await _wire.ReadParameterFloat64Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
return d.IsOk ? ((object)d.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(d.Rc));
case FocasDataType.Bit when address.BitIndex is int bit:
var bi = await _wire.ReadParameterInt32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
if (!bi.IsOk) return (null, FocasStatusMapper.MapFocasReturn(bi.Rc));
return ((object)((bi.Value >> bit & 1) != 0), FocasStatusMapper.Good);
default:
var i = await _wire.ReadParameterInt32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
return i.IsOk ? ((object)i.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(i.Rc));
}
}
catch (FocasWireException ex)
{
return (null, ex.Rc is short rc ? FocasStatusMapper.MapFocasReturn(rc) : FocasStatusMapper.BadCommunicationError);
}
}
private async Task<(object? value, uint status)> ReadMacroAsync(
FocasAddress address, CancellationToken cancellationToken)
{
try
{
var result = await _wire.ReadMacroAsync((short)address.Number, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null)
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
var m = result.Value;
// Macro value is scaled-decimal: the real value is Value / 10^Decimal.
var scaled = m.Value / Math.Pow(10.0, m.Decimal);
return ((object)scaled, FocasStatusMapper.Good);
}
catch (FocasWireException ex)
{
return (null, ex.Rc is short rc ? FocasStatusMapper.MapFocasReturn(rc) : FocasStatusMapper.BadCommunicationError);
}
}
private void RequireConnected()
{
if (!_wire.IsConnected)
throw new InvalidOperationException("FOCAS wire session not connected.");
}
private static void ThrowIfRcNonZero(short rc, string call, bool isOk)
{
if (!isOk) throw new InvalidOperationException($"{call} failed EW_{rc}.");
}
}
/// Factory producing instances — one per configured device.
public sealed class WireFocasClientFactory : IFocasClientFactory
{
private readonly ILogger? _logger;
/// Initializes a new instance of the WireFocasClientFactory without a logger.
public WireFocasClientFactory() : this(logger: null) { }
///
/// Construct the factory with a logger that every created
/// forwards to its . Resolves Driver.FOCAS-007 — the wire
/// client already emits Debug entries per FOCAS response, but the previous no-arg
/// factory path discarded them.
///
/// Optional logger for debug output from wire client responses.
public WireFocasClientFactory(ILogger? logger)
{
_logger = logger;
}
///
/// No-op usability probe — the wire backend is always usable at config time.
/// Implements .
///
public void EnsureUsable() { }
/// Creates a new WireFocasClient instance.
/// A new IFocasClient implementation.
public IFocasClient Create() => new WireFocasClient(_logger);
}