chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
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 = new();
|
||||
private FocasHostAddress? _address;
|
||||
|
||||
public bool IsConnected => _wire.IsConnected;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
public Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(FocasStatusMapper.BadNotWritable);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FocasActiveAlarm>> 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<FocasDynamicSnapshot> 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);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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.Value / Math.Pow(10.0, m.Decimal)))
|
||||
.Where(s => s.AxisName.Length > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
|
||||
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
|
||||
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken);
|
||||
|
||||
private static async Task<IReadOnlyList<int>> 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 [];
|
||||
var list = new List<int>();
|
||||
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;
|
||||
}
|
||||
|
||||
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}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Factory producing <see cref="WireFocasClient"/> instances — one per configured device.</summary>
|
||||
public sealed class WireFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
public IFocasClient Create() => new WireFocasClient();
|
||||
}
|
||||
Reference in New Issue
Block a user