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
|
||||
// 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)
|
||||
{
|
||||
var spindleRoot = deviceFolder.Folder("Spindle", "Spindle");
|
||||
@@ -652,9 +653,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var name = cache.SpindleSegments[i];
|
||||
var spindleFolder = spindleRoot.Folder(name, name);
|
||||
if (cache.Capabilities.SpindleLoad)
|
||||
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "Load", DriverDataType.Int32);
|
||||
if (cache.Capabilities.SpindleMaxRpm && i < cache.SpindleMaxRpms.Count)
|
||||
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
|
||||
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "Load", DriverDataType.Float64);
|
||||
if (cache.Capabilities.SpindleMotorSpeed)
|
||||
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.
|
||||
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 programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(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(
|
||||
Spindles: spindles.Count > 0,
|
||||
SpindleLoad: spindleLoad.Count > 0,
|
||||
SpindleMaxRpm: spindleMaxRpms.Count > 0,
|
||||
SpindleMotorSpeed: spindleMotorSpeeds.Count > 0,
|
||||
ServoLoad: servoLoads.Count > 0,
|
||||
ProgramInfo: programInfo is not null,
|
||||
Timers: timer is not null);
|
||||
|
||||
state.FixedTreeCache = new FocasFixedTreeCache(
|
||||
sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps);
|
||||
state.FixedTreeCache = new FocasFixedTreeCache(sys, [.. axes], [.. spindles], caps);
|
||||
// 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
|
||||
// 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.
|
||||
if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true })
|
||||
if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true } or { SpindleMotorSpeed: true })
|
||||
{
|
||||
try
|
||||
{
|
||||
var client2 = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
var loads = await client2.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
|
||||
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
|
||||
await PollSpindleMetersAsync(state, client2, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1019,17 +1018,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
state.Options.HostAddress);
|
||||
}
|
||||
}
|
||||
if (cache.Capabilities.SpindleLoad)
|
||||
if (cache.Capabilities.SpindleLoad || cache.Capabilities.SpindleMotorSpeed)
|
||||
{
|
||||
try
|
||||
{
|
||||
var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
|
||||
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
|
||||
await PollSpindleMetersAsync(state, client, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
/* transient */
|
||||
_logger.LogDebug(ex, "FOCAS spindle-loads poll failed for {Host}",
|
||||
_logger.LogDebug(ex, "FOCAS spindle-meter poll failed for {Host}",
|
||||
state.Options.HostAddress);
|
||||
}
|
||||
}
|
||||
@@ -1148,7 +1146,27 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
var segments = FixedTreeNames.Disambiguate(loads.Select(l => l.AxisName));
|
||||
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) =>
|
||||
@@ -1240,7 +1258,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
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)
|
||||
&& state.FixedTreeCache is { } spindleCache)
|
||||
{
|
||||
@@ -1259,14 +1277,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
if (idx >= 0)
|
||||
{
|
||||
object? value = field switch
|
||||
var meters = field switch
|
||||
{
|
||||
"Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
|
||||
"MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
|
||||
"Load" => state.LastSpindleLoads,
|
||||
"MotorSpeedPct" => state.LastSpindleMotorSpeeds,
|
||||
_ => null,
|
||||
};
|
||||
if (value is not null)
|
||||
return new DataValueSnapshot(value, status, sourceUtc, now);
|
||||
if (meters is not null && meters.TryGetValue(idx, out var pct))
|
||||
return CachedDouble(pct, status, sourceUtc, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1477,7 +1495,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
FocasSysInfo SysInfo,
|
||||
IReadOnlyList<FocasAxisName> Axes,
|
||||
IReadOnlyList<FocasSpindleName> Spindles,
|
||||
IReadOnlyList<int> SpindleMaxRpms,
|
||||
FocasFixedTreeCapabilities Capabilities)
|
||||
{
|
||||
/// <summary>
|
||||
@@ -1503,14 +1520,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// node suppression so a 16i that doesn't export <c>cnc_rdspmaxrpm</c> simply
|
||||
/// doesn't get a <c>Spindle/{name}/MaxRpm</c> node (instead of surfacing
|
||||
/// node suppression so a 16i that doesn't export <c>cnc_rdspmeter</c> simply
|
||||
/// doesn't get a <c>Spindle/{name}/MotorSpeedPct</c> node (instead of surfacing
|
||||
/// <c>BadDeviceFailure</c> on every read).
|
||||
/// </summary>
|
||||
internal sealed record FocasFixedTreeCapabilities(
|
||||
bool Spindles, // cnc_rdspdlname returned 1+ spindle names
|
||||
bool SpindleLoad, // cnc_rdspload bootstrap probe succeeded
|
||||
bool SpindleMaxRpm, // cnc_rdspmaxrpm bootstrap probe succeeded
|
||||
bool SpindleLoad, // cnc_rdspmeter type 0 (load) bootstrap probe succeeded
|
||||
bool SpindleMotorSpeed, // cnc_rdspmeter type 1 (motor speed) bootstrap probe succeeded
|
||||
bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data
|
||||
bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work
|
||||
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();
|
||||
/// <summary>Gets the last servo load percentages by servo name.</summary>
|
||||
public ConcurrentDictionary<string, double> LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Gets the last spindle load percentages by spindle index.</summary>
|
||||
public ConcurrentDictionary<int, int> LastSpindleLoads { get; } = new();
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public void DisposeClient()
|
||||
|
||||
Reference in New Issue
Block a user