1dba46bd7b
Servo (0x0056) and spindle (0x0040) meter records are 8 bytes:
[i32 value][u16 base][u16 exponent], the same self-describing envelope as the
position blocks.
Servo: offset 4 was read as a decimal count. It is the BASE (a steady 10), so
every load was divided by 10^10 and published ~0 -- the sister ignitionoee panel
read servo 0% on every axis of a Makino cutting at 20,000 rpm. Loads are now
value / base^exponent, a direct percentage.
Spindle: the raw meter is a 1/16384 fraction of full scale. Settled on the real
Z-34184 by regressing the type-1 meter against cnc_rddynamic2 spindle speed over
596..20,000 rpm (rpm = 1.220749 x meter; 16384 x 1.220749 = 20000.75). So:
- Spindle/{name}/Load publishes % of rated load (Float64; was the raw Int32
count -- a reading of 9155 meant 55.9%).
- Spindle/{name}/MaxRpm is RENAMED Spindle/{name}/MotorSpeedPct (Float64, % of
maximum speed) and is polled with Load. The old node was actively wrong: the
type-1 meter is the live motor speed, and it was read once at bootstrap and
served forever as if it were a max-RPM constant. Client-visible: FIXED: tags
on .../MaxRpm must be re-pointed.
- The spindle read no longer stops at the first zero after slot 1 (which dropped
an idle second spindle and kept non-zero residue); every slot is returned.
A record whose envelope is undescribable (base not 2/10, exponent > 18 -- e.g.
residue past the configured axis/spindle count) publishes BadNoData, never a Good 0.
Ported from the sister ignitionoee FOCAS driver v0.17.2/v0.17.3 (Focas.Wire-019);
live captures live-spmeter-{load,maxrpm}-0x0040.bin added as fixtures.
183 lines
9.4 KiB
C#
183 lines
9.4 KiB
C#
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
|
|
|
internal class FakeFocasClient : IFocasClient
|
|
{
|
|
/// <summary>Gets a value indicating whether the client is connected.</summary>
|
|
public bool IsConnected { get; private set; }
|
|
/// <summary>Gets the count of connection attempts.</summary>
|
|
public int ConnectCount { get; private set; }
|
|
/// <summary>Gets the count of dispose operations.</summary>
|
|
public int DisposeCount { get; private set; }
|
|
/// <summary>Gets or sets a value indicating whether to throw on connect.</summary>
|
|
public bool ThrowOnConnect { get; set; }
|
|
/// <summary>Gets or sets a value indicating whether to throw on read.</summary>
|
|
public bool ThrowOnRead { get; set; }
|
|
/// <summary>Gets or sets a value indicating whether to throw on write.</summary>
|
|
public bool ThrowOnWrite { get; set; }
|
|
/// <summary>Gets or sets the result of probe operations.</summary>
|
|
public bool ProbeResult { get; set; } = true;
|
|
/// <summary>Gets or sets the exception to throw.</summary>
|
|
public Exception? Exception { get; set; }
|
|
|
|
/// <summary>Gets the dictionary of read values keyed by address.</summary>
|
|
public Dictionary<string, object?> Values { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
/// <summary>Gets the dictionary of read statuses keyed by address.</summary>
|
|
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
/// <summary>Gets the dictionary of write statuses keyed by address.</summary>
|
|
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
|
/// <summary>Gets the log of write operations.</summary>
|
|
public List<(FocasAddress addr, FocasDataType type, object? value)> WriteLog { get; } = new();
|
|
|
|
/// <summary>Connects to a FOCAS host asynchronously.</summary>
|
|
/// <param name="address">The FOCAS host address.</param>
|
|
/// <param name="timeout">The connection timeout duration.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
public virtual Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
|
|
{
|
|
ConnectCount++;
|
|
if (ThrowOnConnect) throw Exception ?? new InvalidOperationException();
|
|
IsConnected = true;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>Reads a value from a FOCAS address asynchronously.</summary>
|
|
/// <param name="address">The FOCAS address to read from.</param>
|
|
/// <param name="type">The data type of the value.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
public virtual Task<(object? value, uint status)> ReadAsync(
|
|
FocasAddress address, FocasDataType type, CancellationToken ct)
|
|
{
|
|
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
|
var key = address.Canonical;
|
|
var status = ReadStatuses.TryGetValue(key, out var s) ? s : FocasStatusMapper.Good;
|
|
var value = Values.TryGetValue(key, out var v) ? v : null;
|
|
return Task.FromResult((value, status));
|
|
}
|
|
|
|
/// <summary>Writes a value to a FOCAS address asynchronously.</summary>
|
|
/// <param name="address">The FOCAS address to write to.</param>
|
|
/// <param name="type">The data type of the value.</param>
|
|
/// <param name="value">The value to write.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
public virtual Task<uint> WriteAsync(
|
|
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
|
{
|
|
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
|
WriteLog.Add((address, type, value));
|
|
Values[address.Canonical] = value;
|
|
var status = WriteStatuses.TryGetValue(address.Canonical, out var s) ? s : FocasStatusMapper.Good;
|
|
return Task.FromResult(status);
|
|
}
|
|
|
|
/// <summary>Probes the FOCAS connection asynchronously.</summary>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
|
|
|
|
/// <summary>Gets the list of active alarms.</summary>
|
|
public List<FocasActiveAlarm> Alarms { get; } = [];
|
|
|
|
/// <summary>Reads active alarms asynchronously.</summary>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
|
|
|
|
// ---- Fixed-tree T1 ----
|
|
/// <summary>Gets or sets the system information.</summary>
|
|
public FocasSysInfo SysInfo { get; set; } = new(0, 3, "M", "M", "30i", "A1.0", 3);
|
|
/// <summary>Gets the list of axis names.</summary>
|
|
public List<FocasAxisName> AxisNames { get; } = [new("X", ""), new("Y", ""), new("Z", "")];
|
|
/// <summary>Gets the list of spindle names.</summary>
|
|
public List<FocasSpindleName> SpindleNames { get; } = [new("S", "1", "", "")];
|
|
/// <summary>Gets the dictionary of dynamic snapshots keyed by axis index.</summary>
|
|
public Dictionary<int, FocasDynamicSnapshot> DynamicByAxis { get; } = [];
|
|
|
|
/// <summary>Gets system information asynchronously.</summary>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
public virtual Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct) => Task.FromResult(SysInfo);
|
|
/// <summary>Gets axis names asynchronously.</summary>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
public virtual Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<FocasAxisName>>([.. AxisNames]);
|
|
/// <summary>Gets spindle names asynchronously.</summary>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
public virtual Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<FocasSpindleName>>([.. SpindleNames]);
|
|
/// <summary>Reads dynamic data for an axis asynchronously.</summary>
|
|
/// <param name="axisIndex">The zero-based axis index.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
public virtual Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken ct)
|
|
{
|
|
if (!DynamicByAxis.TryGetValue(axisIndex, out var snap))
|
|
snap = new FocasDynamicSnapshot(axisIndex, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
|
|
return Task.FromResult(snap);
|
|
}
|
|
|
|
/// <summary>Gets or sets the program information.</summary>
|
|
public FocasProgramInfo ProgramInfo { get; set; } = new("O0001", 1, 0, 1);
|
|
/// <summary>Gets program information asynchronously.</summary>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
public virtual Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
|
|
Task.FromResult(ProgramInfo);
|
|
|
|
/// <summary>Gets the dictionary of timers keyed by timer kind.</summary>
|
|
public Dictionary<FocasTimerKind, FocasTimer> Timers { get; } = [];
|
|
/// <summary>Gets timer data asynchronously.</summary>
|
|
/// <param name="kind">The timer kind to retrieve.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
public virtual Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken ct)
|
|
{
|
|
if (!Timers.TryGetValue(kind, out var t))
|
|
t = new FocasTimer(kind, 0, 0);
|
|
return Task.FromResult(t);
|
|
}
|
|
|
|
/// <summary>Gets the list of servo loads.</summary>
|
|
public List<FocasServoLoad> ServoLoads { get; } = [];
|
|
/// <summary>Gets servo loads asynchronously.</summary>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
public virtual Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<FocasServoLoad>>([.. ServoLoads]);
|
|
|
|
/// <summary>Gets the list of spindle load percentages (null = undescribable slot).</summary>
|
|
public List<double?> SpindleLoads { get; } = [];
|
|
/// <summary>Gets the list of spindle motor-speed percentages (null = undescribable slot).</summary>
|
|
public List<double?> SpindleMotorSpeeds { get; } = [];
|
|
/// <summary>Gets spindle loads asynchronously.</summary>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
public virtual Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<double?>>([.. SpindleLoads]);
|
|
/// <summary>Gets spindle motor-speed percentages asynchronously.</summary>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
public virtual Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<double?>>([.. SpindleMotorSpeeds]);
|
|
|
|
/// <summary>Disposes the client.</summary>
|
|
public virtual void Dispose()
|
|
{
|
|
DisposeCount++;
|
|
IsConnected = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>A factory for creating fake FOCAS clients.</summary>
|
|
internal sealed class FakeFocasClientFactory : IFocasClientFactory
|
|
{
|
|
/// <summary>Gets the list of created clients.</summary>
|
|
public List<FakeFocasClient> Clients { get; } = new();
|
|
/// <summary>Gets or sets a customization function for creating clients.</summary>
|
|
public Func<FakeFocasClient>? Customise { get; set; }
|
|
|
|
/// <summary>No-op usability probe — the fake factory is always usable.</summary>
|
|
public void EnsureUsable() { }
|
|
|
|
/// <summary>Creates a fake FOCAS client.</summary>
|
|
public IFocasClient Create()
|
|
{
|
|
var c = Customise?.Invoke() ?? new FakeFocasClient();
|
|
Clients.Add(c);
|
|
return c;
|
|
}
|
|
}
|