12b981dc1b
The capability matrix admits #0..99999 on 30i/31i/32i (and anything on Unknown), and the FOCAS/2 request block has always carried 32-bit arguments, but WireFocasClient cast every number to short: MACRO:99999 asked the CNC for #-31073 and published that variable at Good under the authored tag. FocasWireClient's macro/parameter reads, WireMacro.Number and WireParameter.DataNumber are now int; the request blocks come from MacroRequest / ParameterRequest builders (byte-identical to the live 31i-B capture for #500). Same class on PMC: a range whose last byte passes 65535 (Unknown series admits it) is now refused BadOutOfRange instead of wrapping onto a different 16-bit address. The wire client is in-process (the other end is the CNC itself), so this is not a cross-repo contract change. Whether a real 30i honours cnc_rdmacro Arg1 > 32767 is the hardware userGate. Refs #740 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
539 lines
27 KiB
C#
539 lines
27 KiB
C#
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
|
|
|
/// <summary>
|
|
/// <see cref="IFocasClient"/> implementation backed by the in-tree managed
|
|
/// <see cref="FocasWireClient"/>. No P/Invoke, no <c>Fwlib64.dll</c>, no out-of-process
|
|
/// Host — the wire client dials the CNC on TCP:8193 directly and speaks the FOCAS/2
|
|
/// Ethernet binary protocol.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// OtOpcUa is read-only against FOCAS. <see cref="WriteAsync"/> returns
|
|
/// <see cref="FocasStatusMapper.BadNotWritable"/> for every address — the managed wire
|
|
/// client intentionally does not expose <c>cnc_wrparam</c> / <c>pmc_wrpmcrng</c> /
|
|
/// <c>cnc_wrmacro</c>.
|
|
/// </remarks>
|
|
public sealed class WireFocasClient : IFocasClient
|
|
{
|
|
private readonly FocasWireClient _wire;
|
|
private FocasHostAddress? _address;
|
|
|
|
/// <summary>
|
|
/// Default constructor — wire client without logger. Selected by the legacy
|
|
/// no-arg <see cref="WireFocasClientFactory.Create"/> path.
|
|
/// </summary>
|
|
public WireFocasClient() : this(logger: null) { }
|
|
|
|
/// <summary>
|
|
/// Construct with an optional logger. Threaded through to
|
|
/// <see cref="FocasWireClient"/> so the per-response Debug entries actually reach
|
|
/// the host's logging pipeline.
|
|
/// </summary>
|
|
/// <param name="logger">Optional logger for debug output from wire client responses.</param>
|
|
public WireFocasClient(ILogger<FocasWireClient>? logger)
|
|
{
|
|
_wire = new FocasWireClient(logger);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool IsConnected => _wire.IsConnected;
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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, type, cancellationToken).ConfigureAwait(false),
|
|
_ => (null, FocasStatusMapper.BadNotSupported),
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
|
|
string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken)
|
|
{
|
|
// Contract: a RETURNED status is always a CNC return code (a refusal); a transport failure THROWS.
|
|
// The coalescing caller needs that distinction — it falls back per tag on a refusal but must not
|
|
// re-hit a dead link.
|
|
RequireConnected();
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var area = FocasPmcAreaLookup.FromLetter(letter);
|
|
if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown);
|
|
var dataType = FocasPmcDataTypeLookup.FromFocasDataType(slotType);
|
|
if (start < 0 || end > ushort.MaxValue || end < start) return (null, FocasStatusMapper.BadOutOfRange);
|
|
try
|
|
{
|
|
var result = await _wire.ReadPmcRangeAsync(area.Value, dataType, (ushort)start, (ushort)end, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (!result.IsOk || result.Value is null)
|
|
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
|
|
return (result.Value.Values, FocasStatusMapper.Good);
|
|
}
|
|
catch (FocasWireException ex) when (ex.Rc is short rc)
|
|
{
|
|
return (null, FocasStatusMapper.MapFocasReturn(rc));
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<uint> WriteAsync(
|
|
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
|
=> Task.FromResult(FocasStatusMapper.BadNotWritable);
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (!_wire.IsConnected) return false;
|
|
try
|
|
{
|
|
var result = await _wire.ReadStatusAsync(cancellationToken).ConfigureAwait(false);
|
|
return result.IsOk;
|
|
}
|
|
catch (FocasWireException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken)
|
|
{
|
|
// Every failure THROWS — a disconnected session, a non-OK return code and a wire fault
|
|
// alike. This used to answer [] for all three, which the alarm projection could not tell
|
|
// from "no alarms active": every comms blip cleared every active alarm, and recovery re-raised them
|
|
// un-acknowledged. FocasWireException propagates as-is; a refusal (non-OK rc) is thrown carrying its Rc.
|
|
RequireConnected();
|
|
var result = await _wire.ReadAlarmsAsync(FocasAlarmType.All, MaxAlarmsPerRead, cancellationToken).ConfigureAwait(false);
|
|
// A non-OK return code is the CNC REFUSING the call over a healthy transport — thrown carrying the rc, so
|
|
// the alarm projection can tell it from comms loss and keep it out of driver health.
|
|
if (!result.IsOk)
|
|
throw new FocasWireException($"cnc_rdalmmsg2 refused by the CNC (EW_{result.Rc}).", result.Rc);
|
|
return (result.Value ?? []).Select(Map).ToList();
|
|
|
|
static FocasActiveAlarm Map(WireAlarm a) => new(
|
|
AlarmNumber: a.AlarmNumber,
|
|
Type: a.Type,
|
|
Axis: a.Axis,
|
|
Message: a.Message ?? string.Empty);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<FocasSysInfo> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken)
|
|
{
|
|
RequireConnected();
|
|
var result = await _wire.ReadStatusAsync(cancellationToken).ConfigureAwait(false);
|
|
ThrowIfRcNonZero(result.Rc, "cnc_statinfo", result.IsOk);
|
|
return MapStatus(result.Value!);
|
|
}
|
|
|
|
/// <summary>Maps the wire-level status snapshot to the driver's status record.</summary>
|
|
/// <param name="s">The wire status.</param>
|
|
/// <returns>The mapped status.</returns>
|
|
internal static FocasStatus MapStatus(WireStatus s) =>
|
|
new(s.Auto, s.Run, s.Motion, s.Mstb, s.Emergency, s.Alarm, s.Edit, s.TmMode);
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<FocasAxisName>> 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);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<FocasSpindleName>> 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);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken)
|
|
{
|
|
// FOCAS axes are 1-based; cnc_rddynamic2 with axis 0 returns EW_4 (live-confirmed on the
|
|
// 31i-B). The FixedTree poll already iterates 1..AxesCount, but enforce the contract so a
|
|
// future caller can't silently request axis 0. 2026-06-25.
|
|
if (axisIndex < 1)
|
|
throw new ArgumentOutOfRangeException(nameof(axisIndex), axisIndex, "FOCAS axis index is 1-based.");
|
|
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);
|
|
// Positions keep the scale their own 0x0026 block reported; the driver converts them to
|
|
// engineering units at publish (FocasPosition.ToEngineering).
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<FocasProgramInfo> 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}.");
|
|
|
|
return MapProgramInfo(nameResult, blkResult, modeResult);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Combine the three program sub-reads into a <see cref="FocasProgramInfo"/>, leaving any
|
|
/// field the CNC did not report null. A refused read, or an <c>cnc_exeprgname2</c> payload
|
|
/// too short to carry the trailing O-number, used to publish a fabricated <c>0</c> at Good
|
|
/// quality — indistinguishable from a real 0 (a 31i reports O-number 0 for a program run
|
|
/// by path) and, for the mode, from MDI. Ported from the sister ignitionoee driver (101a3b1).
|
|
/// </summary>
|
|
/// <param name="name">The <c>cnc_exeprgname2</c> result.</param>
|
|
/// <param name="blockCount">The <c>cnc_rdblkcount</c> result.</param>
|
|
/// <param name="mode">The <c>cnc_rdopmode</c> result.</param>
|
|
/// <returns>The snapshot, null where the CNC did not say.</returns>
|
|
internal static FocasProgramInfo MapProgramInfo(
|
|
FocasResult<WireProgramName> name, FocasResult<int> blockCount, FocasResult<short> mode)
|
|
{
|
|
var wireName = name.IsOk ? name.Value : null;
|
|
return new FocasProgramInfo(
|
|
Name: wireName?.Name,
|
|
ONumber: wireName?.ONumber,
|
|
BlockCount: blockCount.IsOk ? blockCount.Value : null,
|
|
Mode: mode.IsOk ? mode.Value : null);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<FocasTimer> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<FocasServoLoad>> 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.Scaled))
|
|
.Where(s => s.AxisName.Length > 0)
|
|
.ToList();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
|
|
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
|
|
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMotorSpeedAsync(sel, ct), cancellationToken);
|
|
|
|
/// <summary>
|
|
/// One <c>cnc_rdspmeter</c> read as per-slot percentages (null for an undescribable slot).
|
|
/// Every slot is returned — the driver reads only the slots of discovered spindles. (This
|
|
/// used to stop at the first zero after slot 1, which both dropped a genuinely idle second
|
|
/// spindle and kept non-zero buffer residue.)
|
|
/// </summary>
|
|
private static async Task<IReadOnlyList<double?>> ReadSpindleMetricAsync(
|
|
Func<short, CancellationToken, Task<FocasResult<IReadOnlyList<WireSpindleMetric>>>> call,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await call(-1, cancellationToken).ConfigureAwait(false);
|
|
if (!result.IsOk || result.Value is null) return [];
|
|
return result.Value.Select(m => m.Percent).ToList();
|
|
}
|
|
|
|
/// <summary>Disposes the wire client and releases all resources.</summary>
|
|
public void Dispose() => _wire.Dispose();
|
|
|
|
// ---- PMC / Parameter / Macro read paths ------------------------------------------
|
|
|
|
/// <summary>
|
|
/// The 16-bit byte range <c>pmc_rdpmcrng</c> addresses for a slot of <paramref name="width"/> bytes at
|
|
/// <paramref name="number"/>. False when any byte of it falls outside 0..65535 — the range is refused
|
|
/// rather than wrapped onto a different PMC address (an Unknown-series device admits any number).
|
|
/// </summary>
|
|
/// <param name="number">The authored PMC byte address.</param>
|
|
/// <param name="width">The slot width in bytes (≥ 1).</param>
|
|
/// <param name="start">The first byte, when representable.</param>
|
|
/// <param name="end">The last byte, when representable.</param>
|
|
/// <returns>True when the whole slot is addressable.</returns>
|
|
internal static bool TryPmcRange(int number, int width, out ushort start, out ushort end)
|
|
{
|
|
start = end = 0;
|
|
var last = (long)number + Math.Max(width, 1) - 1;
|
|
if (number < 0 || last > ushort.MaxValue) return false;
|
|
start = (ushort)number;
|
|
end = (ushort)last;
|
|
return true;
|
|
}
|
|
|
|
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);
|
|
// pmc_rdpmcrng returns (end-start+1) BYTES. A multi-byte slot (Word/Long/Real/Double) needs
|
|
// the range widened to its byte width, else the value parser gets too few bytes → 0 values
|
|
// → spurious BadOutOfRange (live-confirmed on the 31i-B: a Word read with end=start returned
|
|
// a single byte). Bit/Byte stay width 1 so end==start. 2026-06-25.
|
|
if (!TryPmcRange(address.Number, FocasWireClient.PmcByteWidth((short)dataType), out var start, out var end))
|
|
return (null, FocasStatusMapper.BadOutOfRange);
|
|
|
|
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);
|
|
// One decode authority shared with the coalesced path (FocasPmcCoalescer.MapSlot).
|
|
var mapped = FocasPmcCoalescer.MapSlot(values[0], type, address.BitIndex);
|
|
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(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(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(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(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(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(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, FocasDataType type, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
var result = await _wire.ReadMacroAsync(address.Number, cancellationToken).ConfigureAwait(false);
|
|
if (!result.IsOk || result.Value is null)
|
|
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
|
|
return MapMacro(result.Value, type, address.BitIndex);
|
|
}
|
|
catch (FocasWireException ex)
|
|
{
|
|
return (null, ex.Rc is short rc ? FocasStatusMapper.MapFocasReturn(rc) : FocasStatusMapper.BadCommunicationError);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Turn a decoded macro into the value + quality its tag publishes.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>A <b>vacant</b> variable has no value at all → <see cref="FocasStatusMapper.BadNoData"/>,
|
|
/// never a Good <c>0</c> (its mantissa is 0, indistinguishable from a genuine zero).</para>
|
|
/// <para>Float tags get <c>mantissa / 10^decimal</c>. <b>Integer</b> tags (Byte / Int16 /
|
|
/// Int32 / Bit) get the exact whole value, tested with integer arithmetic
|
|
/// (<c>mantissa % 10^decimal</c>) so no epsilon is involved; a fractional macro on an
|
|
/// integer tag is <see cref="FocasStatusMapper.BadTypeMismatch"/> — truncating or rounding
|
|
/// would publish a plausible wrong count at Good quality — and a whole value that does not
|
|
/// fit the type is <see cref="FocasStatusMapper.BadOutOfRange"/>. Ported from the sister
|
|
/// ignitionoee driver's <c>macroInteger</c>/<c>macroReal</c>.</para>
|
|
/// </remarks>
|
|
/// <param name="m">The decoded macro.</param>
|
|
/// <param name="type">The tag's declared data type.</param>
|
|
/// <param name="bitIndex">Optional bit index for a <see cref="FocasDataType.Bit"/> tag.</param>
|
|
/// <returns>The boxed value (null when Bad) and its status.</returns>
|
|
internal static (object? value, uint status) MapMacro(WireMacro m, FocasDataType type, int? bitIndex)
|
|
{
|
|
if (m.Scaled is not { } scaled) return (null, FocasStatusMapper.BadNoData);
|
|
|
|
switch (type)
|
|
{
|
|
case FocasDataType.Float64:
|
|
return (scaled, FocasStatusMapper.Good);
|
|
case FocasDataType.Float32:
|
|
return ((float)scaled, FocasStatusMapper.Good);
|
|
case FocasDataType.Byte or FocasDataType.Int16 or FocasDataType.Int32 or FocasDataType.Bit:
|
|
if (!TryWholeMacroValue(m, out var whole)) return (null, FocasStatusMapper.BadTypeMismatch);
|
|
return type switch
|
|
{
|
|
FocasDataType.Bit => (bitIndex is int bit ? ((whole >> bit) & 1L) != 0 : whole != 0, FocasStatusMapper.Good),
|
|
FocasDataType.Byte when whole is >= sbyte.MinValue and <= sbyte.MaxValue => ((sbyte)whole, FocasStatusMapper.Good),
|
|
FocasDataType.Int16 when whole is >= short.MinValue and <= short.MaxValue => ((short)whole, FocasStatusMapper.Good),
|
|
FocasDataType.Int32 when whole is >= int.MinValue and <= int.MaxValue => ((int)whole, FocasStatusMapper.Good),
|
|
_ => (null, FocasStatusMapper.BadOutOfRange),
|
|
};
|
|
default:
|
|
return (scaled, FocasStatusMapper.Good);
|
|
}
|
|
}
|
|
|
|
/// <summary>Exact <c>mantissa / 10^decimal</c> when it is a whole number; integer arithmetic only.</summary>
|
|
private static bool TryWholeMacroValue(WireMacro m, out long whole)
|
|
{
|
|
whole = 0;
|
|
if (m.Value == 0) return true; // 0 is whole at any decimal
|
|
if (m.Decimal > 18) return false; // an i32 mantissa over 10^19+ is always fractional
|
|
var divisor = 1L;
|
|
for (var i = 0; i < m.Decimal; i++) divisor *= 10L;
|
|
if (m.Value % divisor != 0) return false;
|
|
whole = m.Value / divisor;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records requested per <c>cnc_rdalmmsg2</c> call. A reply carrying exactly this many may have been
|
|
/// truncated by the CNC; the alarm projection warns about it.
|
|
/// </summary>
|
|
internal const short MaxAlarmsPerRead = 32;
|
|
|
|
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}.");
|
|
}
|
|
}
|
|
|
|
/// <summary>Factory producing <see cref="WireFocasClient"/> instances — one per configured device.</summary>
|
|
public sealed class WireFocasClientFactory : IFocasClientFactory
|
|
{
|
|
private readonly ILogger<FocasWireClient>? _logger;
|
|
|
|
/// <summary>Initializes a new instance of the WireFocasClientFactory without a logger.</summary>
|
|
public WireFocasClientFactory() : this(logger: null) { }
|
|
|
|
/// <summary>
|
|
/// Construct the factory with a logger that every created <see cref="WireFocasClient"/>
|
|
/// forwards to its <see cref="FocasWireClient"/> — the wire client already emits Debug
|
|
/// entries per FOCAS response, but the previous no-arg factory path discarded them.
|
|
/// </summary>
|
|
/// <param name="logger">Optional logger for debug output from wire client responses.</param>
|
|
public WireFocasClientFactory(ILogger<FocasWireClient>? logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
// No-op — the wire backend is always usable at config time.
|
|
public void EnsureUsable() { }
|
|
|
|
/// <inheritdoc />
|
|
public IFocasClient Create() => new WireFocasClient(_logger);
|
|
}
|