fix(focas): decode servo/spindle meters by their base/exponent envelope; MaxRpm -> MotorSpeedPct
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.
This commit is contained in:
@@ -643,7 +643,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
|
|
||||||
// Spindle subtree — one folder per discovered spindle, suppressed
|
// Spindle subtree — one folder per discovered spindle, suppressed
|
||||||
// entirely on series that don't export cnc_rdspdlname. Per-spindle
|
// entirely on series that don't export cnc_rdspdlname. Per-spindle
|
||||||
// Load + MaxRpm each gated on their own capability probe.
|
// Load + MotorSpeedPct (both cnc_rdspmeter percentages of full scale)
|
||||||
|
// each gated on their own capability probe.
|
||||||
if (cache.Capabilities.Spindles)
|
if (cache.Capabilities.Spindles)
|
||||||
{
|
{
|
||||||
var spindleRoot = deviceFolder.Folder("Spindle", "Spindle");
|
var spindleRoot = deviceFolder.Folder("Spindle", "Spindle");
|
||||||
@@ -652,9 +653,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
var name = cache.SpindleSegments[i];
|
var name = cache.SpindleSegments[i];
|
||||||
var spindleFolder = spindleRoot.Folder(name, name);
|
var spindleFolder = spindleRoot.Folder(name, name);
|
||||||
if (cache.Capabilities.SpindleLoad)
|
if (cache.Capabilities.SpindleLoad)
|
||||||
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "Load", DriverDataType.Int32);
|
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "Load", DriverDataType.Float64);
|
||||||
if (cache.Capabilities.SpindleMaxRpm && i < cache.SpindleMaxRpms.Count)
|
if (cache.Capabilities.SpindleMotorSpeed)
|
||||||
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
|
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "MotorSpeedPct", DriverDataType.Float64);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,7 +928,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
|
|
||||||
// Optional-API probes — each returns empty / throws when unsupported.
|
// Optional-API probes — each returns empty / throws when unsupported.
|
||||||
var spindles = await SafeProbe(() => client.GetSpindleNamesAsync(ct), []);
|
var spindles = await SafeProbe(() => client.GetSpindleNamesAsync(ct), []);
|
||||||
var spindleMaxRpms = await SafeProbe(() => client.GetSpindleMaxRpmsAsync(ct), []);
|
var spindleMotorSpeeds = await SafeProbe(() => client.GetSpindleMotorSpeedsAsync(ct), []);
|
||||||
var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []);
|
var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []);
|
||||||
var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct));
|
var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct));
|
||||||
var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct));
|
var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct));
|
||||||
@@ -936,13 +937,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
var caps = new FocasFixedTreeCapabilities(
|
var caps = new FocasFixedTreeCapabilities(
|
||||||
Spindles: spindles.Count > 0,
|
Spindles: spindles.Count > 0,
|
||||||
SpindleLoad: spindleLoad.Count > 0,
|
SpindleLoad: spindleLoad.Count > 0,
|
||||||
SpindleMaxRpm: spindleMaxRpms.Count > 0,
|
SpindleMotorSpeed: spindleMotorSpeeds.Count > 0,
|
||||||
ServoLoad: servoLoads.Count > 0,
|
ServoLoad: servoLoads.Count > 0,
|
||||||
ProgramInfo: programInfo is not null,
|
ProgramInfo: programInfo is not null,
|
||||||
Timers: timer is not null);
|
Timers: timer is not null);
|
||||||
|
|
||||||
state.FixedTreeCache = new FocasFixedTreeCache(
|
state.FixedTreeCache = new FocasFixedTreeCache(sys, [.. axes], [.. spindles], caps);
|
||||||
sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps);
|
|
||||||
// The bootstrap IS an answer from the device, and Identity comes entirely from it —
|
// The bootstrap IS an answer from the device, and Identity comes entirely from it —
|
||||||
// so stamp liveness here as well as per tick, or a CNC whose axis poll never
|
// so stamp liveness here as well as per tick, or a CNC whose axis poll never
|
||||||
// succeeds would serve a perfectly valid sysinfo as stale.
|
// succeeds would serve a perfectly valid sysinfo as stale.
|
||||||
@@ -958,15 +958,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prime the spindle-loads cache from bootstrap if supported — avoids a
|
// Prime the spindle-meter caches from bootstrap if supported — avoids a
|
||||||
// "tree is there but reads say BadNodeIdUnknown" window on startup.
|
// "tree is there but reads say BadNodeIdUnknown" window on startup.
|
||||||
if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true })
|
if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true } or { SpindleMotorSpeed: true })
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var client2 = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
var client2 = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||||
var loads = await client2.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
|
await PollSpindleMetersAsync(state, client2, ct).ConfigureAwait(false);
|
||||||
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1019,17 +1018,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
state.Options.HostAddress);
|
state.Options.HostAddress);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cache.Capabilities.SpindleLoad)
|
if (cache.Capabilities.SpindleLoad || cache.Capabilities.SpindleMotorSpeed)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
|
await PollSpindleMetersAsync(state, client, ct).ConfigureAwait(false);
|
||||||
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
/* transient */
|
/* transient */
|
||||||
_logger.LogDebug(ex, "FOCAS spindle-loads poll failed for {Host}",
|
_logger.LogDebug(ex, "FOCAS spindle-meter poll failed for {Host}",
|
||||||
state.Options.HostAddress);
|
state.Options.HostAddress);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1148,7 +1146,27 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
{
|
{
|
||||||
var segments = FixedTreeNames.Disambiguate(loads.Select(l => l.AxisName));
|
var segments = FixedTreeNames.Disambiguate(loads.Select(l => l.AxisName));
|
||||||
for (var i = 0; i < loads.Count; i++)
|
for (var i = 0; i < loads.Count; i++)
|
||||||
state.LastServoLoads[segments[i]] = loads[i].LoadPercent;
|
state.LastServoLoads[segments[i]] = loads[i].LoadPercent ?? NoValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read and cache both spindle meters (load, motor speed) — each a <c>cnc_rdspmeter</c>
|
||||||
|
/// percentage of full scale (16384 = 100%) — for every spindle slot, as capability allows.
|
||||||
|
/// A slot whose record describes no value is cached as <see cref="NoValue"/> (BadNoData).
|
||||||
|
/// </summary>
|
||||||
|
private static async Task PollSpindleMetersAsync(DeviceState state, IFocasClient client, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var caps = state.FixedTreeCache?.Capabilities;
|
||||||
|
if (caps is { SpindleLoad: true })
|
||||||
|
{
|
||||||
|
var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
|
||||||
|
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i] ?? NoValue;
|
||||||
|
}
|
||||||
|
if (caps is { SpindleMotorSpeed: true })
|
||||||
|
{
|
||||||
|
var speeds = await client.GetSpindleMotorSpeedsAsync(ct).ConfigureAwait(false);
|
||||||
|
for (var i = 0; i < speeds.Count; i++) state.LastSpindleMotorSpeeds[i] = speeds[i] ?? NoValue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object? TimerValue(DeviceState state, FocasTimerKind kind) =>
|
private static object? TimerValue(DeviceState state, FocasTimerKind kind) =>
|
||||||
@@ -1240,7 +1258,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
return CachedDouble(load, status, sourceUtc, now);
|
return CachedDouble(load, status, sourceUtc, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spindle matches: "Spindle/{name}/Load" + "Spindle/{name}/MaxRpm"
|
// Spindle matches: "Spindle/{name}/Load" + "Spindle/{name}/MotorSpeedPct"
|
||||||
if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
|
if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
|
||||||
&& state.FixedTreeCache is { } spindleCache)
|
&& state.FixedTreeCache is { } spindleCache)
|
||||||
{
|
{
|
||||||
@@ -1259,14 +1277,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
if (idx >= 0)
|
if (idx >= 0)
|
||||||
{
|
{
|
||||||
object? value = field switch
|
var meters = field switch
|
||||||
{
|
{
|
||||||
"Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
|
"Load" => state.LastSpindleLoads,
|
||||||
"MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
|
"MotorSpeedPct" => state.LastSpindleMotorSpeeds,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
if (value is not null)
|
if (meters is not null && meters.TryGetValue(idx, out var pct))
|
||||||
return new DataValueSnapshot(value, status, sourceUtc, now);
|
return CachedDouble(pct, status, sourceUtc, now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1477,7 +1495,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
FocasSysInfo SysInfo,
|
FocasSysInfo SysInfo,
|
||||||
IReadOnlyList<FocasAxisName> Axes,
|
IReadOnlyList<FocasAxisName> Axes,
|
||||||
IReadOnlyList<FocasSpindleName> Spindles,
|
IReadOnlyList<FocasSpindleName> Spindles,
|
||||||
IReadOnlyList<int> SpindleMaxRpms,
|
|
||||||
FocasFixedTreeCapabilities Capabilities)
|
FocasFixedTreeCapabilities Capabilities)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1503,14 +1520,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-device optional-API capability flags — which of the "this may or may not
|
/// Per-device optional-API capability flags — which of the "this may or may not
|
||||||
/// exist on this CNC series" calls succeeded at bootstrap. Drives per-series
|
/// exist on this CNC series" calls succeeded at bootstrap. Drives per-series
|
||||||
/// node suppression so a 16i that doesn't export <c>cnc_rdspmaxrpm</c> simply
|
/// node suppression so a 16i that doesn't export <c>cnc_rdspmeter</c> simply
|
||||||
/// doesn't get a <c>Spindle/{name}/MaxRpm</c> node (instead of surfacing
|
/// doesn't get a <c>Spindle/{name}/MotorSpeedPct</c> node (instead of surfacing
|
||||||
/// <c>BadDeviceFailure</c> on every read).
|
/// <c>BadDeviceFailure</c> on every read).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed record FocasFixedTreeCapabilities(
|
internal sealed record FocasFixedTreeCapabilities(
|
||||||
bool Spindles, // cnc_rdspdlname returned 1+ spindle names
|
bool Spindles, // cnc_rdspdlname returned 1+ spindle names
|
||||||
bool SpindleLoad, // cnc_rdspload bootstrap probe succeeded
|
bool SpindleLoad, // cnc_rdspmeter type 0 (load) bootstrap probe succeeded
|
||||||
bool SpindleMaxRpm, // cnc_rdspmaxrpm bootstrap probe succeeded
|
bool SpindleMotorSpeed, // cnc_rdspmeter type 1 (motor speed) bootstrap probe succeeded
|
||||||
bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data
|
bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data
|
||||||
bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work
|
bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work
|
||||||
bool Timers); // cnc_rdtimer works for at least PowerOn
|
bool Timers); // cnc_rdtimer works for at least PowerOn
|
||||||
@@ -1580,8 +1597,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
public ConcurrentDictionary<FocasTimerKind, FocasTimer> LastTimers { get; } = new();
|
public ConcurrentDictionary<FocasTimerKind, FocasTimer> LastTimers { get; } = new();
|
||||||
/// <summary>Gets the last servo load percentages by servo name.</summary>
|
/// <summary>Gets the last servo load percentages by servo name.</summary>
|
||||||
public ConcurrentDictionary<string, double> LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
|
public ConcurrentDictionary<string, double> LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
/// <summary>Gets the last spindle load percentages by spindle index.</summary>
|
/// <summary>
|
||||||
public ConcurrentDictionary<int, int> LastSpindleLoads { get; } = new();
|
/// Gets the last spindle load percentages (% of rated load) by spindle index;
|
||||||
|
/// <see cref="NoValue"/> for a slot the CNC described as holding none.
|
||||||
|
/// </summary>
|
||||||
|
public ConcurrentDictionary<int, double> LastSpindleLoads { get; } = new();
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the last spindle motor-speed percentages (% of maximum speed) by spindle index;
|
||||||
|
/// <see cref="NoValue"/> for a slot the CNC described as holding none.
|
||||||
|
/// </summary>
|
||||||
|
public ConcurrentDictionary<int, double> LastSpindleMotorSpeeds { get; } = new();
|
||||||
|
|
||||||
/// <summary>Disposes the FOCAS client instance.</summary>
|
/// <summary>Disposes the FOCAS client instance.</summary>
|
||||||
public void DisposeClient()
|
public void DisposeClient()
|
||||||
|
|||||||
@@ -140,9 +140,10 @@ public interface IFocasClient : IDisposable
|
|||||||
// ---- Fixed-tree T3.5 (servo meters) ----
|
// ---- Fixed-tree T3.5 (servo meters) ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read the servo-load meter percentages across all configured axes.
|
/// Read the servo-load meter percentages across all configured axes. Each record's own
|
||||||
/// Values are percentages (scaled by <c>10^Dec</c>). Empty list on a
|
/// <c>value / base^exponent</c> envelope is applied (servo loads are direct percentages);
|
||||||
/// disconnected session or unsupported CNC.
|
/// a record whose envelope describes no value carries a null <see cref="FocasServoLoad.LoadPercent"/>.
|
||||||
|
/// Empty list on a disconnected session or unsupported CNC.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
/// <returns>The servo-load meter percentages across all configured axes.</returns>
|
/// <returns>The servo-load meter percentages across all configured axes.</returns>
|
||||||
@@ -151,26 +152,31 @@ public interface IFocasClient : IDisposable
|
|||||||
// ---- Fixed-tree T3.6 (spindle meters) ----
|
// ---- Fixed-tree T3.6 (spindle meters) ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read per-spindle load percentages. Result list index corresponds to
|
/// Read per-spindle load as a percentage of rated load (<c>cnc_rdspmeter</c> type 0; the
|
||||||
/// spindle index from <see cref="GetSpindleNamesAsync"/>. Empty list on a
|
/// raw meter is a 1/16384 fraction of full scale). Result list index corresponds to
|
||||||
/// disconnected session or when the CNC doesn't support the call (older
|
/// spindle index from <see cref="GetSpindleNamesAsync"/>; a slot whose record describes no
|
||||||
/// series like 16i may return EW_FUNC).
|
/// value is null. Empty list on a disconnected session or when the CNC doesn't support the
|
||||||
|
/// call (older series like 16i may return EW_FUNC).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
/// <returns>Per-spindle load percentages, indexed as per <see cref="GetSpindleNamesAsync"/>.</returns>
|
/// <returns>Per-spindle load percentages, indexed as per <see cref="GetSpindleNamesAsync"/>.</returns>
|
||||||
Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
|
Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read per-spindle maximum RPM values. Static configuration, fetched once at
|
/// Read per-spindle motor speed as a percentage of maximum speed (<c>cnc_rdspmeter</c>
|
||||||
/// bootstrap. Index alignment as per <see cref="GetSpindleLoadsAsync"/>.
|
/// type 1, historically labelled <c>cnc_rdspmaxrpm</c>). A live value, not a max-RPM
|
||||||
|
/// constant. Index alignment and null semantics as per <see cref="GetSpindleLoadsAsync"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
/// <returns>Per-spindle maximum RPM values, indexed as per <see cref="GetSpindleLoadsAsync"/>.</returns>
|
/// <returns>Per-spindle motor-speed percentages, indexed as per <see cref="GetSpindleLoadsAsync"/>.</returns>
|
||||||
Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken);
|
Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One servo-meter entry — one axis's current load percentage.</summary>
|
/// <summary>
|
||||||
public sealed record FocasServoLoad(string AxisName, double LoadPercent);
|
/// One servo-meter entry — one axis's current load percentage; null when the CNC's record
|
||||||
|
/// described no usable value (never a fabricated 0).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasServoLoad(string AxisName, double? LoadPercent);
|
||||||
|
|
||||||
/// <summary>Which cumulative counter <see cref="IFocasClient.GetTimerAsync"/> reads.</summary>
|
/// <summary>Which cumulative counter <see cref="IFocasClient.GetTimerAsync"/> reads.</summary>
|
||||||
public enum FocasTimerKind
|
public enum FocasTimerKind
|
||||||
@@ -297,19 +303,16 @@ public sealed record FocasDynamicSnapshot(
|
|||||||
/// </param>
|
/// </param>
|
||||||
public readonly record struct FocasPosition(int Raw, int Base = 0, int Exponent = 0, bool HasScale = false)
|
public readonly record struct FocasPosition(int Raw, int Base = 0, int Exponent = 0, bool HasScale = false)
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Largest exponent that can describe a real coordinate. An i32 carries at most ten digits,
|
|
||||||
/// so anything beyond is a misread block — and left unbounded, <c>raw / 10^400</c> is
|
|
||||||
/// <c>raw / Infinity = 0.0</c>, a Good reading of a machine parked at the origin.
|
|
||||||
/// </summary>
|
|
||||||
public const int MaxExponent = 18;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// False when the block says it holds no usable position: the <c>0xFFFF</c> vacant marker, a
|
/// False when the block says it holds no usable position: the <c>0xFFFF</c> vacant marker, a
|
||||||
/// base other than 2 or 10, or an absurd exponent. A per-node quality decision — that node
|
/// base other than 2 or 10, or an absurd exponent. A per-node quality decision — that node
|
||||||
/// publishes Bad, its siblings are unaffected.
|
/// publishes Bad, its siblings are unaffected.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsValid => !HasScale || (Exponent <= MaxExponent && Base is 2 or 10);
|
/// <remarks>
|
||||||
|
/// The exponent bound matters: left unbounded, <c>raw / 10^400</c> is
|
||||||
|
/// <c>raw / Infinity = 0.0</c> — a Good reading of a machine parked at the origin.
|
||||||
|
/// </remarks>
|
||||||
|
public bool IsValid => !HasScale || Wire.FocasValueEnvelope.IsValid(Base, Exponent);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The position in engineering units: <c>Raw / Base^Exponent</c> when the block carried its
|
/// The position in engineering units: <c>Raw / Base^Exponent</c> when the block carried its
|
||||||
@@ -321,7 +324,7 @@ public readonly record struct FocasPosition(int Raw, int Base = 0, int Exponent
|
|||||||
public double? ToEngineering(int fallbackDecimalPlaces)
|
public double? ToEngineering(int fallbackDecimalPlaces)
|
||||||
{
|
{
|
||||||
if (!IsValid) return null;
|
if (!IsValid) return null;
|
||||||
if (HasScale) return Exponent == 0 ? Raw : Raw / Math.Pow(Base, Exponent);
|
if (HasScale) return Wire.FocasValueEnvelope.Scale(Raw, Base, Exponent);
|
||||||
var dp = Math.Max(0, fallbackDecimalPlaces);
|
var dp = Math.Max(0, fallbackDecimalPlaces);
|
||||||
return dp > 0 ? Raw / Math.Pow(10, dp) : Raw;
|
return dp > 0 ? Raw / Math.Pow(10, dp) : Raw;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,12 +99,12 @@ public sealed class SynchronizedFocasClient : IFocasClient
|
|||||||
RunBoundedAsync(ct => _inner.GetServoLoadsAsync(ct), cancellationToken);
|
RunBoundedAsync(ct => _inner.GetServoLoadsAsync(ct), cancellationToken);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
|
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
|
||||||
RunBoundedAsync(ct => _inner.GetSpindleLoadsAsync(ct), cancellationToken);
|
RunBoundedAsync(ct => _inner.GetSpindleLoadsAsync(ct), cancellationToken);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
|
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
|
||||||
RunBoundedAsync(ct => _inner.GetSpindleMaxRpmsAsync(ct), cancellationToken);
|
RunBoundedAsync(ct => _inner.GetSpindleMotorSpeedsAsync(ct), cancellationToken);
|
||||||
|
|
||||||
/// <summary>Disposes the inner client and the call gate.</summary>
|
/// <summary>Disposes the inner client and the call gate.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -420,12 +420,15 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
|||||||
return new FocasResult<IReadOnlyList<WireServoMeter>>(rc, result);
|
return new FocasResult<IReadOnlyList<WireServoMeter>>(rc, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Read per-spindle load percentages via <c>cnc_rdspload</c> (command <c>0x0040</c> with arg1=0).</summary>
|
/// <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="spindleSelector">Spindle selector; -1 selects all spindles.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||||
/// <returns>The per-spindle load percentages, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
/// <returns>The per-spindle load meters, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||||
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleLoadAsync(
|
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleLoadAsync(
|
||||||
short spindleSelector = -1,
|
short spindleSelector = -1,
|
||||||
CancellationToken cancellationToken = default,
|
CancellationToken cancellationToken = default,
|
||||||
@@ -433,13 +436,19 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
|||||||
ushort? pathId = null)
|
ushort? pathId = null)
|
||||||
=> ReadSpindleMetricAsync(0, spindleSelector, cancellationToken, timeout, pathId);
|
=> ReadSpindleMetricAsync(0, spindleSelector, cancellationToken, timeout, pathId);
|
||||||
|
|
||||||
/// <summary>Read per-spindle maximum RPMs via <c>cnc_rdspmaxrpm</c> (command <c>0x0040</c> with arg1=1).</summary>
|
/// <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="spindleSelector">Spindle selector; -1 selects all spindles.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||||
/// <param name="timeout">Optional per-call timeout override.</param>
|
/// <param name="timeout">Optional per-call timeout override.</param>
|
||||||
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
|
||||||
/// <returns>The per-spindle maximum RPMs, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
/// <returns>The per-spindle motor-speed meters, wrapped in a <see cref="FocasResult{T}"/>.</returns>
|
||||||
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMaxRpmAsync(
|
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMotorSpeedAsync(
|
||||||
short spindleSelector = -1,
|
short spindleSelector = -1,
|
||||||
CancellationToken cancellationToken = default,
|
CancellationToken cancellationToken = default,
|
||||||
TimeSpan? timeout = null,
|
TimeSpan? timeout = null,
|
||||||
@@ -771,13 +780,27 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
|||||||
callTimeout.Token,
|
callTimeout.Token,
|
||||||
new RequestBlock(0x0040, metric, spindleSelector, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
new RequestBlock(0x0040, metric, spindleSelector, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
|
||||||
|
|
||||||
return ToResult<IReadOnlyList<WireSpindleMetric>>(block, payload =>
|
return ToResult(block, ParseSpindleMetrics);
|
||||||
{
|
}
|
||||||
var values = new List<WireSpindleMetric>();
|
|
||||||
for (var offset = 0; offset + 8 <= payload.Length; offset += 8)
|
/// <summary>
|
||||||
values.Add(new WireSpindleMetric((short)(values.Count + 1), ReadInt32(payload, offset)));
|
/// Decode a <c>cnc_rdspmeter</c> (0x0040) payload: 8-byte records of
|
||||||
return values;
|
/// <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>(
|
private async Task<FocasResult<T>> ReadSingleAsync<T>(
|
||||||
@@ -958,18 +981,16 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
|||||||
payload.Length >= 8 ? BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(4, 4)) : 0);
|
payload.Length >= 8 ? BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(4, 4)) : 0);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Decode a <c>cnc_rdsvmeter</c> response into <see cref="WireServoMeter"/> records. On the
|
/// Decode a <c>cnc_rdsvmeter</c> response into <see cref="WireServoMeter"/> records. Each
|
||||||
/// 31i-B (v3) each per-axis LOADELM is <b>8 bytes</b> — {int32 data; int16 dec; int16 unit}
|
/// per-axis record is <b>8 bytes</b> — <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c>,
|
||||||
/// — NOT the 12-byte shape the original parser assumed. The 12-byte stride misaligned after
|
/// <c>[+6..7] u16 exponent</c> — the same self-describing envelope as the position blocks,
|
||||||
/// the first record (it read the dec/unit shorts of the prior record as the next record's
|
/// so the load is <c>value / base^exponent</c>, a direct percentage. (A 12-byte stride
|
||||||
/// data → wild values like 655360). Axis NAMES are not in the svmeter payload; they come
|
/// misaligned after the first record; reading offset 4 as a decimal count divided every load
|
||||||
/// from the <c>cnc_rdaxisname</c> (0x0089) block requested alongside it and are correlated
|
/// by 10^10 — both fixed against live 31i-B captures, the latter by the sister ignitionoee
|
||||||
/// by index. Validated against a live 31i-B 2026-06-25.
|
/// driver, Focas.Wire-019.) Axis NAMES are not in the svmeter payload; they come from the
|
||||||
/// <para><b>Scaling caveat:</b> downstream applies LoadPercent = data / 10^dec; on the 31i-B
|
/// <c>cnc_rdaxisname</c> (0x0089) block requested alongside it and are correlated by index.
|
||||||
/// <c>dec</c> read as 10, which makes idle loads vanishingly small. The data/dec/unit field
|
/// The payload is a fixed 32-slot buffer: slots past the controlled-axis count are residue
|
||||||
/// semantics for servo load are inferred from the wire and not yet confirmed against the
|
/// and decode as <see cref="WireServoMeter.IsValid"/> = false.
|
||||||
/// machine's servo-meter screen — confirm magnitude at commissioning. The alignment + name
|
|
||||||
/// fix here is what removes the gross misaligned garbage.</para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="svPayload">The raw <c>cnc_rdsvmeter</c> response payload.</param>
|
/// <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="axisNamePayload">The raw <c>cnc_rdaxisname</c> response payload used to correlate axis names by index.</param>
|
||||||
@@ -989,8 +1010,8 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
|||||||
(short)(index + 1),
|
(short)(index + 1),
|
||||||
name,
|
name,
|
||||||
ReadInt32(svPayload, offset),
|
ReadInt32(svPayload, offset),
|
||||||
ReadInt16(svPayload, offset + 4),
|
BinaryPrimitives.ReadUInt16BigEndian(svPayload.AsSpan(offset + 4, 2)), // BASE, not a decimal
|
||||||
ReadInt16(svPayload, offset + 6)));
|
BinaryPrimitives.ReadUInt16BigEndian(svPayload.AsSpan(offset + 6, 2))));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,18 +59,104 @@ public sealed record WireDynamic(
|
|||||||
int SpindleSpeed,
|
int SpindleSpeed,
|
||||||
WireAxisPosition Axis);
|
WireAxisPosition Axis);
|
||||||
|
|
||||||
/// <summary>One servo-meter entry from <c>cnc_rdsvmeter</c> — per-axis load percentage (scale by 10^<see cref="Decimal"/>).</summary>
|
/// <summary>
|
||||||
|
/// The self-describing <c>(value, base, exponent)</c> envelope shared by every value record on
|
||||||
|
/// the FOCAS wire — <c>cnc_rddynamic2</c> positions, servo meters, spindle meters:
|
||||||
|
/// <c>scaled = value / base^exponent</c>, where only base 2 and base 10 are meaningful.
|
||||||
|
/// </summary>
|
||||||
|
internal static class FocasValueEnvelope
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Largest exponent that can describe a real value. An i32 carries at most ten digits, so
|
||||||
|
/// anything beyond is a misread record — and <c>value / 10^400</c> would be
|
||||||
|
/// <c>value / Infinity = 0.0</c>, a plausible Good zero.
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxExponent = 18;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the record describes a usable value. A fixed multi-slot buffer's slots past the
|
||||||
|
/// configured axis/spindle count hold unrelated memory, which shows up exactly here: a base
|
||||||
|
/// that is neither 2 nor 10, or an absurd exponent (incl. the <c>0xFFFF</c> vacant marker).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="base">The record's base field.</param>
|
||||||
|
/// <param name="exponent">The record's exponent field.</param>
|
||||||
|
/// <returns>Whether the record may be published.</returns>
|
||||||
|
public static bool IsValid(int @base, int exponent) => exponent <= MaxExponent && @base is 2 or 10;
|
||||||
|
|
||||||
|
/// <summary><c>value / base^exponent</c>, or null when <see cref="IsValid"/> is false.</summary>
|
||||||
|
/// <param name="value">The raw value.</param>
|
||||||
|
/// <param name="base">The record's base field.</param>
|
||||||
|
/// <param name="exponent">The record's exponent field.</param>
|
||||||
|
/// <returns>The scaled value, or null.</returns>
|
||||||
|
public static double? Scale(int value, int @base, int exponent) =>
|
||||||
|
!IsValid(@base, exponent) ? null : exponent == 0 ? value : value / Math.Pow(@base, exponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One servo-meter entry from <c>cnc_rdsvmeter</c> (0x0056): one axis's load. The 8-byte record
|
||||||
|
/// is <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c>, <c>[+6..7] u16 exponent</c> and the load
|
||||||
|
/// is a <b>direct</b> percentage, <see cref="Scaled"/> = <c>value / base^exponent</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Offset 4 used to be read as a decimal count ("data / 10^dec"). It is the <b>base</b> — a
|
||||||
|
/// steady 10 — so every load was divided by 10^10 and published ~0 (a real Makino cutting at
|
||||||
|
/// 20,000 rpm read servo 0% on every axis). Found by the sister ignitionoee driver,
|
||||||
|
/// Focas.Wire-019 (v0.17.2).
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Index">1-based axis slot.</param>
|
||||||
|
/// <param name="Name">Axis name from the companion 0x0089 block (empty when none).</param>
|
||||||
|
/// <param name="Value">Raw load value.</param>
|
||||||
|
/// <param name="Base">Scale base (2 or 10 when meaningful).</param>
|
||||||
|
/// <param name="Exponent">Scale exponent.</param>
|
||||||
public sealed record WireServoMeter(
|
public sealed record WireServoMeter(
|
||||||
short Index,
|
short Index,
|
||||||
string Name,
|
string Name,
|
||||||
int Value,
|
int Value,
|
||||||
short Decimal,
|
int Base,
|
||||||
short Unit);
|
int Exponent)
|
||||||
|
{
|
||||||
|
/// <summary>False for a record whose envelope describes no usable value (buffer residue).</summary>
|
||||||
|
public bool IsValid => FocasValueEnvelope.IsValid(Base, Exponent);
|
||||||
|
|
||||||
/// <summary>One spindle metric slot from <c>cnc_rdspload</c> / <c>cnc_rdspmaxrpm</c>.</summary>
|
/// <summary>The load percentage, <c>Value / Base^Exponent</c>; null when <see cref="IsValid"/> is false.</summary>
|
||||||
|
public double? Scaled => FocasValueEnvelope.Scale(Value, Base, Exponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One spindle-meter slot from <c>cnc_rdspmeter</c> (0x0040; type 0 = load, type 1 = motor
|
||||||
|
/// speed). Same 8-byte envelope as <see cref="WireServoMeter"/>, but the scaled value is a
|
||||||
|
/// fraction of full scale in <c>1/16384</c> units — see <see cref="Percent"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Index">1-based spindle slot.</param>
|
||||||
|
/// <param name="Value">Raw meter value, in 1/16384-of-full-scale units.</param>
|
||||||
|
/// <param name="Base">Scale base.</param>
|
||||||
|
/// <param name="Exponent">Scale exponent.</param>
|
||||||
public sealed record WireSpindleMetric(
|
public sealed record WireSpindleMetric(
|
||||||
short Index,
|
short Index,
|
||||||
int Value);
|
int Value,
|
||||||
|
int Base,
|
||||||
|
int Exponent)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The meter's full-scale count: <c>16384</c> = 2^14 = 100% of rated load (type 0) or of
|
||||||
|
/// maximum spindle speed (type 1). Settled on the real Z-34184 by regressing the type-1
|
||||||
|
/// meter against <c>cnc_rddynamic2</c> spindle speed over 596–20,000 rpm:
|
||||||
|
/// <c>rpm = 1.220749 × meter</c>, <c>16384 × 1.220749 = 20000.75</c> (Focas.Wire-019).
|
||||||
|
/// </summary>
|
||||||
|
public const int FullScale = 1 << 14;
|
||||||
|
|
||||||
|
/// <summary>False for a record whose envelope describes no usable value (buffer residue).</summary>
|
||||||
|
public bool IsValid => FocasValueEnvelope.IsValid(Base, Exponent);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The meter as a percentage — <c>(Value / Base^Exponent) × 100 / 16384</c> — or null when
|
||||||
|
/// <see cref="IsValid"/> is false. Values above 100 are real (short-term overload; the i16
|
||||||
|
/// record clamps one count short of 200%).
|
||||||
|
/// </summary>
|
||||||
|
public double? Percent => FocasValueEnvelope.Scale(Value, Base, Exponent) is { } scaled
|
||||||
|
? scaled * 100.0 / FullScale
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One axis-name slot from <c>cnc_rdaxisname</c>. <see cref="Index"/> is the 1-based
|
/// One axis-name slot from <c>cnc_rdaxisname</c>. <see cref="Index"/> is the 1-based
|
||||||
|
|||||||
@@ -242,34 +242,32 @@ public sealed class WireFocasClient : IFocasClient
|
|||||||
var result = await _wire.ReadServoMeterAsync(32, cancellationToken).ConfigureAwait(false);
|
var result = await _wire.ReadServoMeterAsync(32, cancellationToken).ConfigureAwait(false);
|
||||||
if (!result.IsOk || result.Value is null) return [];
|
if (!result.IsOk || result.Value is null) return [];
|
||||||
return result.Value
|
return result.Value
|
||||||
.Select(m => new FocasServoLoad(m.Name ?? string.Empty, m.Value / Math.Pow(10.0, m.Decimal)))
|
.Select(m => new FocasServoLoad(m.Name ?? string.Empty, m.Scaled))
|
||||||
.Where(s => s.AxisName.Length > 0)
|
.Where(s => s.AxisName.Length > 0)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
|
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
|
||||||
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken);
|
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
|
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
|
||||||
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken);
|
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMotorSpeedAsync(sel, ct), cancellationToken);
|
||||||
|
|
||||||
private static async Task<IReadOnlyList<int>> ReadSpindleMetricAsync(
|
/// <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,
|
Func<short, CancellationToken, Task<FocasResult<IReadOnlyList<WireSpindleMetric>>>> call,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await call(-1, cancellationToken).ConfigureAwait(false);
|
var result = await call(-1, cancellationToken).ConfigureAwait(false);
|
||||||
if (!result.IsOk || result.Value is null) return [];
|
if (!result.IsOk || result.Value is null) return [];
|
||||||
var list = new List<int>();
|
return result.Value.Select(m => m.Percent).ToList();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Disposes the wire client and releases all resources.</summary>
|
/// <summary>Disposes the wire client and releases all resources.</summary>
|
||||||
|
|||||||
@@ -140,18 +140,18 @@ internal class FakeFocasClient : IFocasClient
|
|||||||
public virtual Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) =>
|
public virtual Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) =>
|
||||||
Task.FromResult<IReadOnlyList<FocasServoLoad>>([.. ServoLoads]);
|
Task.FromResult<IReadOnlyList<FocasServoLoad>>([.. ServoLoads]);
|
||||||
|
|
||||||
/// <summary>Gets the list of spindle loads.</summary>
|
/// <summary>Gets the list of spindle load percentages (null = undescribable slot).</summary>
|
||||||
public List<int> SpindleLoads { get; } = [];
|
public List<double?> SpindleLoads { get; } = [];
|
||||||
/// <summary>Gets the list of spindle maximum RPMs.</summary>
|
/// <summary>Gets the list of spindle motor-speed percentages (null = undescribable slot).</summary>
|
||||||
public List<int> SpindleMaxRpms { get; } = [];
|
public List<double?> SpindleMotorSpeeds { get; } = [];
|
||||||
/// <summary>Gets spindle loads asynchronously.</summary>
|
/// <summary>Gets spindle loads asynchronously.</summary>
|
||||||
/// <param name="ct">The cancellation token.</param>
|
/// <param name="ct">The cancellation token.</param>
|
||||||
public virtual Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken ct) =>
|
public virtual Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken ct) =>
|
||||||
Task.FromResult<IReadOnlyList<int>>([.. SpindleLoads]);
|
Task.FromResult<IReadOnlyList<double?>>([.. SpindleLoads]);
|
||||||
/// <summary>Gets spindle maximum RPMs asynchronously.</summary>
|
/// <summary>Gets spindle motor-speed percentages asynchronously.</summary>
|
||||||
/// <param name="ct">The cancellation token.</param>
|
/// <param name="ct">The cancellation token.</param>
|
||||||
public virtual Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken ct) =>
|
public virtual Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken ct) =>
|
||||||
Task.FromResult<IReadOnlyList<int>>([.. SpindleMaxRpms]);
|
Task.FromResult<IReadOnlyList<double?>>([.. SpindleMotorSpeeds]);
|
||||||
|
|
||||||
/// <summary>Disposes the client.</summary>
|
/// <summary>Disposes the client.</summary>
|
||||||
public virtual void Dispose()
|
public virtual void Dispose()
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+11
-11
@@ -34,8 +34,8 @@ public sealed class FocasDuplicateSiblingNameTests
|
|||||||
// Two spindles whose displays also collide, with distinct data per slot.
|
// Two spindles whose displays also collide, with distinct data per slot.
|
||||||
c.SpindleNames.Clear();
|
c.SpindleNames.Clear();
|
||||||
c.SpindleNames.AddRange([new("S", "1", "", ""), new("S", "1", "", "")]);
|
c.SpindleNames.AddRange([new("S", "1", "", ""), new("S", "1", "", "")]);
|
||||||
c.SpindleLoads.AddRange([5, 7]);
|
c.SpindleLoads.AddRange([5.0, 7.0]);
|
||||||
c.SpindleMaxRpms.AddRange([8_000, 12_000]);
|
c.SpindleMotorSpeeds.AddRange([40.0, 60.0]);
|
||||||
return c;
|
return c;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -159,17 +159,17 @@ public sealed class FocasDuplicateSiblingNameTests
|
|||||||
// The spindle read path matched by display name and took the FIRST index that matched,
|
// The spindle read path matched by display name and took the FIRST index that matched,
|
||||||
// so a duplicate spindle name made the second spindle's data unreachable.
|
// so a duplicate spindle name made the second spindle's data unreachable.
|
||||||
var drv = await StartedDriverAsync(
|
var drv = await StartedDriverAsync(
|
||||||
new FocasTagDefinition("raw/s1load", Host, "FIXED:Spindle/S1_1/Load", FocasDataType.Int32, Writable: false),
|
new FocasTagDefinition("raw/s1load", Host, "FIXED:Spindle/S1_1/Load", FocasDataType.Float64, Writable: false),
|
||||||
new FocasTagDefinition("raw/s2load", Host, "FIXED:Spindle/S1_2/Load", FocasDataType.Int32, Writable: false),
|
new FocasTagDefinition("raw/s2load", Host, "FIXED:Spindle/S1_2/Load", FocasDataType.Float64, Writable: false),
|
||||||
new FocasTagDefinition("raw/s1max", Host, "FIXED:Spindle/S1_1/MaxRpm", FocasDataType.Int32, Writable: false),
|
new FocasTagDefinition("raw/s1speed", Host, "FIXED:Spindle/S1_1/MotorSpeedPct", FocasDataType.Float64, Writable: false),
|
||||||
new FocasTagDefinition("raw/s2max", Host, "FIXED:Spindle/S1_2/MaxRpm", FocasDataType.Int32, Writable: false));
|
new FocasTagDefinition("raw/s2speed", Host, "FIXED:Spindle/S1_2/MotorSpeedPct", FocasDataType.Float64, Writable: false));
|
||||||
|
|
||||||
var read = await drv.ReadAsync(["raw/s1load", "raw/s2load", "raw/s1max", "raw/s2max"], CancellationToken.None);
|
var read = await drv.ReadAsync(["raw/s1load", "raw/s2load", "raw/s1speed", "raw/s2speed"], CancellationToken.None);
|
||||||
|
|
||||||
read[0].Value.ShouldBe(5);
|
read[0].Value.ShouldBe(5.0);
|
||||||
read[1].Value.ShouldBe(7);
|
read[1].Value.ShouldBe(7.0);
|
||||||
read[2].Value.ShouldBe(8_000);
|
read[2].Value.ShouldBe(40.0);
|
||||||
read[3].Value.ShouldBe(12_000);
|
read[3].Value.ShouldBe(60.0);
|
||||||
read.ShouldAllBe(r => r.StatusCode == FocasStatusMapper.Good);
|
read.ShouldAllBe(r => r.StatusCode == FocasStatusMapper.Good);
|
||||||
|
|
||||||
await drv.ShutdownAsync(CancellationToken.None);
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Servo (<c>cnc_rdsvmeter</c>, 0x0056) and spindle (<c>cnc_rdspmeter</c>, 0x0040) meter decode.
|
||||||
|
/// Both are arrays of 8-byte records carrying the same self-describing envelope as every other
|
||||||
|
/// value block on this wire — <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c> (2 or 10),
|
||||||
|
/// <c>[+6..7] u16 exponent</c> — so <c>scaled = value / base^exponent</c>.
|
||||||
|
/// Ported from the sister ignitionoee FOCAS driver v0.17.2 / v0.17.3 (Focas.Wire-019).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Servo: offset 4 is the BASE, not a decimal count.</b> It holds a steady 10, so the
|
||||||
|
/// old <c>value / 10^decimal</c> divided every load by 10^10 and published ~0 — on a real
|
||||||
|
/// Makino cutting at 20,000 rpm the panel read servo 0% on every axis. Servo meters are
|
||||||
|
/// <b>direct</b> percentages (the live capture shows −57..227).</para>
|
||||||
|
/// <para><b>Spindle: a fraction of full scale in 1/16384 units.</b> Settled on the real Z-34184
|
||||||
|
/// by regressing the type-1 meter against the independently read <c>cnc_rddynamic2</c> spindle
|
||||||
|
/// speed over 596–20,000 rpm: <c>rpm = 1.220749 × meter</c>, and <c>16384 × 1.220749 =
|
||||||
|
/// 20000.75</c> — full scale is the spindle's 20,000 rpm maximum. So the type-1 meter
|
||||||
|
/// (<c>cnc_rdspmaxrpm</c> by name) is the <b>motor speed as a % of maximum</b>, not a max-rpm
|
||||||
|
/// constant; the type-0 load meter shares the envelope and the 16384 = 100% scale (its
|
||||||
|
/// acceleration peaks pin at 32767 — one count short of a 200% overload).</para>
|
||||||
|
/// </remarks>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FocasMeterDecodeTests
|
||||||
|
{
|
||||||
|
private static byte[] Record(int value, int @base, int exponent)
|
||||||
|
{
|
||||||
|
var b = new byte[8];
|
||||||
|
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(0, 4), value);
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(4, 2), (ushort)@base);
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(6, 2), (ushort)exponent);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- servo ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_live_servo_capture_reads_direct_percentages_not_value_over_ten_to_the_tenth()
|
||||||
|
{
|
||||||
|
var blocks = WireFixtures.Blocks("svmeter-pair-0x0056-0x0089.bin");
|
||||||
|
var meters = FocasWireClient.ParseServoMeters(
|
||||||
|
blocks.First(b => b.Command == 0x0056).Payload,
|
||||||
|
blocks.First(b => b.Command == 0x0089).Payload,
|
||||||
|
maxCount: 32);
|
||||||
|
|
||||||
|
meters[0].Base.ShouldBe(10);
|
||||||
|
meters[0].Exponent.ShouldBe(0);
|
||||||
|
meters.Take(3).Select(m => m.Scaled).ShouldBe([0.0, 50.0, -3.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_servo_record_applies_its_own_exponent()
|
||||||
|
{
|
||||||
|
var m = FocasWireClient.ParseServoMeters(Record(1234, 10, 1), Array.Empty<byte>(), 32).Single();
|
||||||
|
m.Scaled!.Value.ShouldBe(123.4, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(5, 7, 0)] // base neither 2 nor 10
|
||||||
|
[InlineData(5, 10, 2560)] // residue past the controlled-axis count
|
||||||
|
public void An_undescribable_servo_record_has_no_value_not_zero(int value, int @base, int exponent)
|
||||||
|
{
|
||||||
|
var m = FocasWireClient.ParseServoMeters(Record(value, @base, exponent), Array.Empty<byte>(), 32).Single();
|
||||||
|
m.IsValid.ShouldBeFalse();
|
||||||
|
m.Scaled.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- spindle ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_live_spindle_load_capture_is_a_percentage_of_16384()
|
||||||
|
{
|
||||||
|
var metrics = FocasWireClient.ParseSpindleMetrics(
|
||||||
|
WireFixtures.Payload("live-spmeter-load-0x0040.bin", 0x0040));
|
||||||
|
|
||||||
|
// Slot 1: raw 0x23C3 = 9155, base 10, exponent 0 → 9155 × 100 / 16384 ≈ 55.88 %.
|
||||||
|
metrics[0].Value.ShouldBe(9155);
|
||||||
|
metrics[0].Percent!.Value.ShouldBe(9155 * 100.0 / 16384, 1e-9);
|
||||||
|
// Slot 2 is buffer residue (exponent 0x0A00 = 2560): no value, not a number.
|
||||||
|
metrics[1].IsValid.ShouldBeFalse();
|
||||||
|
metrics[1].Percent.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_live_motor_speed_capture_is_a_percentage_of_maximum_speed()
|
||||||
|
{
|
||||||
|
var metrics = FocasWireClient.ParseSpindleMetrics(
|
||||||
|
WireFixtures.Payload("live-spmeter-maxrpm-0x0040.bin", 0x0040));
|
||||||
|
|
||||||
|
// 0x3FFD = 16381 ≈ 99.98 % — a spindle at (just under) its 20,000 rpm maximum.
|
||||||
|
metrics[0].Value.ShouldBe(16381);
|
||||||
|
metrics[0].Percent!.Value.ShouldBe(16381 * 100.0 / 16384, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(16382, 19998)]
|
||||||
|
[InlineData(12286, 14998)]
|
||||||
|
[InlineData(9830, 12000)]
|
||||||
|
[InlineData(695, 850)]
|
||||||
|
[InlineData(488, 595)]
|
||||||
|
public void Percent_reproduces_the_live_motor_speed_regression(int meter, int rpm)
|
||||||
|
{
|
||||||
|
var m = FocasWireClient.ParseSpindleMetrics(Record(meter, 10, 0)).Single();
|
||||||
|
m.Percent!.Value.ShouldBe(rpm * 100.0 / 20000.0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Full_scale_is_16384_and_overload_is_not_clamped()
|
||||||
|
{
|
||||||
|
WireSpindleMetric.FullScale.ShouldBe(16384);
|
||||||
|
FocasWireClient.ParseSpindleMetrics(Record(16384, 10, 0)).Single().Percent.ShouldBe(100.0);
|
||||||
|
var overload = FocasWireClient.ParseSpindleMetrics(Record(32767, 10, 0)).Single().Percent!.Value;
|
||||||
|
overload.ShouldBeInRange(199.9, 200.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_spindle_envelope_exponent_applies_before_the_full_scale_division()
|
||||||
|
{
|
||||||
|
// 163840 / 10^1 = 16384 counts = 100 %.
|
||||||
|
FocasWireClient.ParseSpindleMetrics(Record(163840, 10, 1)).Single().Percent!.Value.ShouldBe(100.0, 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver-level publish of servo / spindle meters (Focas.Wire-019 port): percentages publish
|
||||||
|
/// Good as Float64, an undescribable record publishes BadNoData (never a Good 0), and the
|
||||||
|
/// spindle motor-speed meter is a live, polled <c>MotorSpeedPct</c> — not the bootstrap-time
|
||||||
|
/// <c>MaxRpm</c> constant it was mislabelled as.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FocasMeterPublishTests
|
||||||
|
{
|
||||||
|
private const string Host = "focas://10.0.0.5:8193";
|
||||||
|
|
||||||
|
private static FocasDriver NewDriver(FakeFocasClient client) =>
|
||||||
|
new(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions(Host)],
|
||||||
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
FixedTree = new FocasFixedTreeOptions { Enabled = true, PollInterval = TimeSpan.FromMilliseconds(10) },
|
||||||
|
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
|
||||||
|
|
||||||
|
private static async Task<DataValueSnapshot> ReadSettledAsync(FocasDriver drv, string reference, Func<DataValueSnapshot, bool> settled)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||||
|
DataValueSnapshot last;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
last = (await drv.ReadAsync([reference], CancellationToken.None)).Single();
|
||||||
|
if (settled(last)) return last;
|
||||||
|
await Task.Delay(15);
|
||||||
|
} while (DateTime.UtcNow < deadline);
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FakeFocasClient MachineClient()
|
||||||
|
{
|
||||||
|
var c = new FakeFocasClient();
|
||||||
|
c.AxisNames.Clear();
|
||||||
|
c.AxisNames.AddRange([new("X", ""), new("Y", "")]);
|
||||||
|
c.ServoLoads.AddRange([new("X", 46.0), new("Y", null)]); // Y: undescribable record
|
||||||
|
c.SpindleNames.Clear();
|
||||||
|
c.SpindleNames.Add(new("S", "1", "", ""));
|
||||||
|
c.SpindleLoads.Add(55.88);
|
||||||
|
c.SpindleMotorSpeeds.Add(2.49);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Servo_loads_publish_percentages_and_an_undescribable_record_is_bad()
|
||||||
|
{
|
||||||
|
var drv = NewDriver(MachineClient());
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var x = await ReadSettledAsync(drv, $"{Host}/Axes/X/ServoLoad", s => s.StatusCode == FocasStatusMapper.Good);
|
||||||
|
var y = await ReadSettledAsync(drv, $"{Host}/Axes/Y/ServoLoad", s => s.StatusCode == FocasStatusMapper.BadNoData);
|
||||||
|
|
||||||
|
x.Value.ShouldBe(46.0);
|
||||||
|
y.StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
|
||||||
|
y.Value.ShouldBeNull();
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Spindle_meters_publish_float64_percentages_and_motor_speed_is_polled_live()
|
||||||
|
{
|
||||||
|
var client = MachineClient();
|
||||||
|
var drv = NewDriver(client);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var load = await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.Good);
|
||||||
|
load.Value.ShouldBe(55.88);
|
||||||
|
(await ReadSettledAsync(drv, $"{Host}/Spindle/S1/MotorSpeedPct", s => s.StatusCode == FocasStatusMapper.Good))
|
||||||
|
.Value.ShouldBe(2.49);
|
||||||
|
|
||||||
|
// The spindle speeds up: the poll loop must follow it (MaxRpm was read once at bootstrap).
|
||||||
|
client.SpindleMotorSpeeds[0] = 99.98;
|
||||||
|
(await ReadSettledAsync(drv, $"{Host}/Spindle/S1/MotorSpeedPct", s => Equals(s.Value, 99.98)))
|
||||||
|
.Value.ShouldBe(99.98);
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_undescribable_spindle_slot_is_bad_not_zero()
|
||||||
|
{
|
||||||
|
var client = MachineClient();
|
||||||
|
client.SpindleLoads[0] = null;
|
||||||
|
var drv = NewDriver(client);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var load = await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.BadNoData);
|
||||||
|
|
||||||
|
load.StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
|
||||||
|
load.Value.ShouldBeNull();
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_spindle_folder_offers_MotorSpeedPct_not_MaxRpm()
|
||||||
|
{
|
||||||
|
var drv = NewDriver(MachineClient());
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.Good);
|
||||||
|
|
||||||
|
var builder = new NameRecordingBuilder();
|
||||||
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||||
|
|
||||||
|
builder.Variables.ShouldContain("MotorSpeedPct");
|
||||||
|
builder.Variables.ShouldNotContain("MaxRpm");
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NameRecordingBuilder : IAddressSpaceBuilder
|
||||||
|
{
|
||||||
|
public List<string> Variables { get; } = [];
|
||||||
|
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||||
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||||
|
{
|
||||||
|
Variables.Add(browseName);
|
||||||
|
return new Handle(attributeInfo.FullName);
|
||||||
|
}
|
||||||
|
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||||
|
|
||||||
|
private sealed class Handle(string fullRef) : IVariableHandle
|
||||||
|
{
|
||||||
|
public string FullReference => fullRef;
|
||||||
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,7 +98,7 @@ public sealed class FocasWireV3Tests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ParseServoMeters_uses_an_eight_byte_stride_and_names_from_the_axis_block()
|
public void ParseServoMeters_uses_an_eight_byte_stride_and_names_from_the_axis_block()
|
||||||
{
|
{
|
||||||
// Three 8-byte records {int32 data; int16 dec=10; int16 unit=0}: data = 0, 50, -3.
|
// Three 8-byte records {int32 value; u16 base=10; u16 exponent=0}: value = 0, 50, -3.
|
||||||
byte[] sv =
|
byte[] sv =
|
||||||
[
|
[
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, // axis 1: data=0
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, // axis 1: data=0
|
||||||
@@ -120,7 +120,8 @@ public sealed class FocasWireV3Tests
|
|||||||
meters[0].Value.ShouldBe(0);
|
meters[0].Value.ShouldBe(0);
|
||||||
meters[1].Name.ShouldBe("Y");
|
meters[1].Name.ShouldBe("Y");
|
||||||
meters[1].Value.ShouldBe(50); // 8-byte stride: a 12-byte stride would misread this as 655360
|
meters[1].Value.ShouldBe(50); // 8-byte stride: a 12-byte stride would misread this as 655360
|
||||||
meters[1].Decimal.ShouldBe((short)10);
|
meters[1].Base.ShouldBe(10); // offset 4 is the envelope BASE, not a decimal count
|
||||||
|
meters[1].Scaled.ShouldBe(50.0);
|
||||||
meters[2].Name.ShouldBe("Z");
|
meters[2].Name.ShouldBe("Z");
|
||||||
meters[2].Value.ShouldBe(-3);
|
meters[2].Value.ShouldBe(-3);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user