fix(focas): browse-committed fixed-tree tags are readable (Gitea #540)
Authoring FOCAS tags through /raw -> Browse device... produced tags the driver
could never read. Every committed tag read BadNodeIdUnknown forever, and the
failure was silent end to end: the AdminUI accepted it, the deploy gate sealed
green, and the driver reported Healthy with 0 errors while ignoring the tags.
Net effect: the whole fixed-tree surface could not be published through any v3
authoring path. Found cutting wonder-app-vd03 over to v3 against a real Makino
Pro 5 / Fanuc 30i, where 0 of 20 committed tags read while two hand-authored
MACRO: tags on the same device read Good.
A captured browse leaf's id IS its DriverAttributeInfo.FullName, and the fixed
tree emitted "{deviceHost}/{path}" there -- an internal cache key, not an
address. Both authoring paths wrote it verbatim into TagConfig.address, and
FocasAddress.TryParse accepts no such form, so the driver skipped the tags at
init.
Fixed at the emitter rather than by stripping the host at commit, which fixes
the browse-commit flow and the manual picker's tree-select together and leaves
one canonical address form:
- FocasAddress gains a fourth kind, FIXED:<path>, and moves to Contracts so the
AdminUI and ControlPlane can parse without referencing the runtime driver
(#526). A FIXED: read is served from the fixed-tree snapshot the poll loop
already maintains -- no area read, no connection. Writes refuse: nothing in
that tree is a writable location on the CNC.
- The address carries no device. A tag's device comes from its Device row, and
a second routing channel for one fact is what created this defect.
- An authored FIXED: tag arms the fixed-tree poll loop by itself. Enabled
defaults false and the browser force-enables it only for a browse, so the
operator would otherwise browse, commit, deploy, and land on a driver whose
loop never starts -- every tag Bad with a correct address.
- Fixed-tree values are now dated from the last answered poll and go
BadCommunicationError once it is stale (8 cadences, floored at 30s). The poll
loop swallows a failed tick by design, so a dead CNC would otherwise serve its
last snapshot as Good indefinitely -- harmless while nothing could bind to the
tree, not harmless now.
Guardrails, so this class cannot recur silently:
- FocasTagConfigModel.Validate() runs the real parser instead of a non-empty
check, and FocasTagDefinitionFactory.Inspect() warns at the deploy gate, which
covers CSV/API config that never touches the modal.
- The manual picker offered axis/spindle/program/status "parameter groups" --
none of which is a FOCAS addressing space -- and its own default value,
axis:5, was unusable. It now composes PMC / PARAM: / MACRO: through
FocasAddress, so what it emits is parseable by construction.
Tests assert addresses ROUND-TRIP through the parser rather than matching an
expected string: the previous FocasAddressBuilderTests pinned "axis:5" exactly,
which is how a green suite coexisted with a picker nobody could use. Verified
red-first against the old emitter. FOCAS 308, AdminUI 1095, ControlPlane 169,
Core 581 -- all pass, 0 warnings.
The remaining half of #540 -- a driver reporting Healthy/0 errors while
skipping tags at init -- is fleet-wide (eight drivers do this) and is filed
separately rather than patched per-driver.
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed FOCAS address covering the four addressing spaces a driver touches:
|
||||
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
|
||||
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
|
||||
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), <see cref="FocasAreaKind.Macro"/>
|
||||
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>), and
|
||||
/// <see cref="FocasAreaKind.Fixed"/> (a logical path into the driver's fixed tree —
|
||||
/// <c>FIXED:Identity/SeriesNumber</c>, <c>FIXED:Axes/X/AbsolutePosition</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
||||
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
|
||||
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
|
||||
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.</para>
|
||||
/// <para>
|
||||
/// <b><c>FIXED:</c> is not an area read.</b> The first three forms name a location the driver
|
||||
/// asks the CNC for on demand; a <c>FIXED:</c> path names a field of the per-device fixed-tree
|
||||
/// snapshot the driver's own poll loop already maintains (<c>cnc_sysinfo</c>, <c>cnc_rddynamic2</c>,
|
||||
/// program info, timers, spindle data). It exists because the fixed tree is the only part of the
|
||||
/// FOCAS surface with no address of its own, and v3 publishes nodes only for authored tags — so
|
||||
/// without a form to author, the fixed tree could not be published at all (Gitea #540).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The path carries no device.</b> A tag's device comes from its raw-tag Device row, and an
|
||||
/// address that also named a host would be a second, conflicting routing channel — which is
|
||||
/// precisely the defect #540 reported: the browse leaf's <c>{host}/{path}</c> full reference was
|
||||
/// written into the address field verbatim and then failed to parse, so every browse-committed
|
||||
/// fixed-tree tag read <c>BadNodeIdUnknown</c> forever.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record FocasAddress(
|
||||
FocasAreaKind Kind,
|
||||
string? PmcLetter,
|
||||
int Number,
|
||||
int? BitIndex,
|
||||
string? FixedPath = null)
|
||||
{
|
||||
/// <summary>The prefix that introduces a <see cref="FocasAreaKind.Fixed"/> address.</summary>
|
||||
public const string FixedPrefix = "FIXED:";
|
||||
|
||||
/// <summary>Gets the canonical string representation of this address.</summary>
|
||||
public string Canonical => Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc => BitIndex is null
|
||||
? $"{PmcLetter}{Number}"
|
||||
: $"{PmcLetter}{Number}.{BitIndex}",
|
||||
FocasAreaKind.Parameter => BitIndex is null
|
||||
? $"PARAM:{Number}"
|
||||
: $"PARAM:{Number}/{BitIndex}",
|
||||
FocasAreaKind.Macro => $"MACRO:{Number}",
|
||||
FocasAreaKind.Fixed => FixedPrefix + FixedPath,
|
||||
_ => $"?{Number}",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the canonical authored address for a fixed-tree path. The one place the
|
||||
/// <c>FIXED:</c> spelling is constructed, so the browse emitter and the parser cannot drift.
|
||||
/// </summary>
|
||||
/// <param name="path">The fixed-tree path, e.g. <c>Identity/SeriesNumber</c>.</param>
|
||||
/// <returns>The canonical authored address string.</returns>
|
||||
public static string ForFixedPath(string path) => FixedPrefix + path;
|
||||
|
||||
/// <summary>Attempts to parse a FOCAS address from the given string.</summary>
|
||||
/// <param name="value">The address string to parse.</param>
|
||||
/// <returns>A FocasAddress if parsing succeeds; otherwise null.</returns>
|
||||
public static FocasAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var src = value.Trim();
|
||||
|
||||
if (src.StartsWith("PARAM:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseScoped(src["PARAM:".Length..], FocasAreaKind.Parameter, bitSeparator: '/');
|
||||
|
||||
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
|
||||
|
||||
// Fixed-tree path. Tested BEFORE the PMC branch on purpose: "F" is a valid PMC letter, so a
|
||||
// FIXED: address entering that branch would be diagnosed as a malformed PMC address rather
|
||||
// than as the fixed-tree address it is. (It would still return null either way — this is
|
||||
// about which rule owns the string, not about the result.)
|
||||
if (src.StartsWith(FixedPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var path = NormalizeFixedPath(src[FixedPrefix.Length..]);
|
||||
return path is null ? null : new FocasAddress(FocasAreaKind.Fixed, null, 0, null, path);
|
||||
}
|
||||
|
||||
// PMC path: letter + digits + optional .bit
|
||||
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
||||
var letter = src[0..1].ToUpperInvariant();
|
||||
if (!IsValidPmcLetter(letter)) return null;
|
||||
|
||||
var remainder = src[1..];
|
||||
int? bit = null;
|
||||
var dotIdx = remainder.IndexOf('.');
|
||||
if (dotIdx >= 0)
|
||||
{
|
||||
if (!int.TryParse(remainder[(dotIdx + 1)..], out var bitValue) || bitValue is < 0 or > 7)
|
||||
return null;
|
||||
bit = bitValue;
|
||||
remainder = remainder[..dotIdx];
|
||||
}
|
||||
if (!int.TryParse(remainder, out var number) || number < 0) return null;
|
||||
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit);
|
||||
}
|
||||
|
||||
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
|
||||
{
|
||||
int? bit = null;
|
||||
if (bitSeparator is char sep)
|
||||
{
|
||||
var slashIdx = body.IndexOf(sep);
|
||||
if (slashIdx >= 0)
|
||||
{
|
||||
if (!int.TryParse(body[(slashIdx + 1)..], out var bitValue) || bitValue is < 0 or > 31)
|
||||
return null;
|
||||
bit = bitValue;
|
||||
body = body[..slashIdx];
|
||||
}
|
||||
}
|
||||
if (!int.TryParse(body, out var number) || number < 0) return null;
|
||||
return new FocasAddress(kind, PmcLetter: null, number, bit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a fixed-tree path and returns it, or null when it is not a usable path.
|
||||
/// Accepts <c>Segment[/Segment…]</c> where each segment is non-blank and carries no
|
||||
/// <c>:</c> — a colon would collide with the <c>PARAM:</c>/<c>MACRO:</c>/<c>FIXED:</c>
|
||||
/// prefix grammar and make the address ambiguous to read back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately shape-only: it does NOT check the path against the fixed tree a particular
|
||||
/// CNC actually exposes. What that tree contains is per-device and discovered at runtime
|
||||
/// (spindles, program info and timers are each capability-gated), so a parser that judged
|
||||
/// membership would have to reject addresses that are valid on the machine they were
|
||||
/// authored against. An unknown path resolves to no value at read time, which is where the
|
||||
/// device is known.
|
||||
/// </remarks>
|
||||
private static string? NormalizeFixedPath(string body)
|
||||
{
|
||||
var path = body.Trim();
|
||||
if (path.Length == 0) return null;
|
||||
if (path.Contains(':', StringComparison.Ordinal)) return null;
|
||||
if (path[0] == '/' || path[^1] == '/') return null;
|
||||
|
||||
foreach (var segment in path.Split('/'))
|
||||
if (string.IsNullOrWhiteSpace(segment)) return null;
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static bool IsValidPmcLetter(string letter) => letter switch
|
||||
{
|
||||
"X" or "Y" or "F" or "G" or "R" or "D" or "C" or "K" or "A" or "E" or "T" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Addressing-space kinds the driver understands.</summary>
|
||||
public enum FocasAreaKind
|
||||
{
|
||||
/// <summary>PMC area — letter + byte + optional bit (<c>X0.0</c>, <c>R100</c>).</summary>
|
||||
Pmc,
|
||||
|
||||
/// <summary>CNC parameter number (<c>PARAM:1020</c>, <c>PARAM:1815/0</c>).</summary>
|
||||
Parameter,
|
||||
|
||||
/// <summary>Macro variable number (<c>MACRO:100</c>).</summary>
|
||||
Macro,
|
||||
|
||||
/// <summary>
|
||||
/// A logical path into the driver's per-device fixed tree (<c>FIXED:Identity/SeriesNumber</c>).
|
||||
/// Served from the fixed-tree poll snapshot, never from an area read — and read-only, because
|
||||
/// nothing in that tree is a writable location on the CNC.
|
||||
/// </summary>
|
||||
Fixed,
|
||||
}
|
||||
@@ -117,6 +117,21 @@ public static class FocasTagDefinitionFactory
|
||||
}
|
||||
var w = TagConfigJson.DescribeInvalidEnum<FocasDataType>(root, "dataType");
|
||||
if (w is not null) warnings.Add(w);
|
||||
// Gitea #540: an address the driver cannot parse is skipped at init and reads
|
||||
// BadNodeIdUnknown forever, while driver health stays Healthy with zero errors. The
|
||||
// AdminUI modal rejects it outright; this covers the paths that never touch the modal
|
||||
// (CSV import, the API) by at least surfacing it on the deployment.
|
||||
if (root.TryGetProperty("address", out var addr)
|
||||
&& addr.ValueKind == JsonValueKind.String
|
||||
&& addr.GetString() is { } address
|
||||
&& !string.IsNullOrWhiteSpace(address)
|
||||
&& FocasAddress.TryParse(address) is null)
|
||||
{
|
||||
warnings.Add(
|
||||
$"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " +
|
||||
"PARAM:1020, MACRO:500 or FIXED:Identity/SeriesNumber) — the driver will skip " +
|
||||
"this tag at startup and it will read BadNodeIdUnknown.");
|
||||
}
|
||||
if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
|
||||
warnings.Add("FOCAS wire is read-only — an authored 'writable:true' still fails at the wire with BadNotWritable.");
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed FOCAS address covering the three addressing spaces a driver touches:
|
||||
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
|
||||
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
|
||||
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), and <see cref="FocasAreaKind.Macro"/>
|
||||
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
||||
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
|
||||
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
|
||||
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
|
||||
/// </remarks>
|
||||
public sealed record FocasAddress(
|
||||
FocasAreaKind Kind,
|
||||
string? PmcLetter,
|
||||
int Number,
|
||||
int? BitIndex)
|
||||
{
|
||||
/// <summary>Gets the canonical string representation of this address.</summary>
|
||||
public string Canonical => Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc => BitIndex is null
|
||||
? $"{PmcLetter}{Number}"
|
||||
: $"{PmcLetter}{Number}.{BitIndex}",
|
||||
FocasAreaKind.Parameter => BitIndex is null
|
||||
? $"PARAM:{Number}"
|
||||
: $"PARAM:{Number}/{BitIndex}",
|
||||
FocasAreaKind.Macro => $"MACRO:{Number}",
|
||||
_ => $"?{Number}",
|
||||
};
|
||||
|
||||
/// <summary>Attempts to parse a FOCAS address from the given string.</summary>
|
||||
/// <param name="value">The address string to parse.</param>
|
||||
/// <returns>A FocasAddress if parsing succeeds; otherwise null.</returns>
|
||||
public static FocasAddress? TryParse(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var src = value.Trim();
|
||||
|
||||
if (src.StartsWith("PARAM:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseScoped(src["PARAM:".Length..], FocasAreaKind.Parameter, bitSeparator: '/');
|
||||
|
||||
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
|
||||
|
||||
// PMC path: letter + digits + optional .bit
|
||||
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
||||
var letter = src[0..1].ToUpperInvariant();
|
||||
if (!IsValidPmcLetter(letter)) return null;
|
||||
|
||||
var remainder = src[1..];
|
||||
int? bit = null;
|
||||
var dotIdx = remainder.IndexOf('.');
|
||||
if (dotIdx >= 0)
|
||||
{
|
||||
if (!int.TryParse(remainder[(dotIdx + 1)..], out var bitValue) || bitValue is < 0 or > 7)
|
||||
return null;
|
||||
bit = bitValue;
|
||||
remainder = remainder[..dotIdx];
|
||||
}
|
||||
if (!int.TryParse(remainder, out var number) || number < 0) return null;
|
||||
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit);
|
||||
}
|
||||
|
||||
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
|
||||
{
|
||||
int? bit = null;
|
||||
if (bitSeparator is char sep)
|
||||
{
|
||||
var slashIdx = body.IndexOf(sep);
|
||||
if (slashIdx >= 0)
|
||||
{
|
||||
if (!int.TryParse(body[(slashIdx + 1)..], out var bitValue) || bitValue is < 0 or > 31)
|
||||
return null;
|
||||
bit = bitValue;
|
||||
body = body[..slashIdx];
|
||||
}
|
||||
}
|
||||
if (!int.TryParse(body, out var number) || number < 0) return null;
|
||||
return new FocasAddress(kind, PmcLetter: null, number, bit);
|
||||
}
|
||||
|
||||
private static bool IsValidPmcLetter(string letter) => letter switch
|
||||
{
|
||||
"X" or "Y" or "F" or "G" or "R" or "D" or "C" or "K" or "A" or "E" or "T" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Addressing-space kinds the driver understands.</summary>
|
||||
public enum FocasAreaKind
|
||||
{
|
||||
Pmc,
|
||||
Parameter,
|
||||
Macro,
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Documented-API capability matrix — per CNC series, what ranges each
|
||||
/// Documented-API capability matrix — per CNC series, what ranges each addressed
|
||||
/// <see cref="FocasAreaKind"/> supports. Authoritative source for the driver's
|
||||
/// pre-flight validation in <see cref="FocasDriver.InitializeAsync"/>.
|
||||
/// </summary>
|
||||
@@ -35,6 +35,10 @@ public static class FocasCapabilityMatrix
|
||||
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
|
||||
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
|
||||
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
|
||||
// Fixed-tree paths are not area reads, so there is no per-series numeric range to check.
|
||||
// What a given CNC actually exposes there is discovered at runtime and capability-gated
|
||||
// per subtree; a path this machine does not have resolves to BadNodeIdUnknown at read.
|
||||
FocasAreaKind.Fixed => null,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,6 +49,21 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// produced equipment tags are encountered for the first time.
|
||||
private readonly ConcurrentDictionary<string, FocasAddress> _parsedAddressesByTagName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>
|
||||
/// Whether this driver instance runs its per-device fixed-tree poll loop, and therefore
|
||||
/// serves fixed-tree values. Computed at <see cref="InitializeAsync"/> from the config flag
|
||||
/// OR the presence of at least one authored <see cref="FocasAreaKind.Fixed"/> tag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>The OR is load-bearing.</b> <c>FocasFixedTreeOptions.Enabled</c> defaults to
|
||||
/// <see langword="false"/> so an upgrade does not grow nodes on configs that never asked for
|
||||
/// them, and the universal browser force-enables it for the duration of a browse only. An
|
||||
/// operator therefore browses the fixed tree, commits leaves from it, deploys — and lands on
|
||||
/// a driver whose loop never starts, so every one of those tags reads Bad with a correct
|
||||
/// address. Authoring a <c>FIXED:</c> tag IS the request for the tree; the config flag stays
|
||||
/// as the way to publish it without authoring anything.
|
||||
/// </remarks>
|
||||
private bool _fixedTreeActive;
|
||||
private FocasAlarmProjection? _alarmProjection;
|
||||
// _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync).
|
||||
// Volatile.Read/Write ensures every thread sees the latest reference without a lock — the
|
||||
@@ -248,7 +263,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
if (_options.AlarmProjection.Enabled)
|
||||
_alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval, _logger);
|
||||
|
||||
if (_options.FixedTree.Enabled)
|
||||
// See _fixedTreeActive: an authored FIXED: tag arms the loop on its own, because the
|
||||
// config flag defaults off and the operator who committed those tags has already said
|
||||
// what they want.
|
||||
_fixedTreeActive = _options.FixedTree.Enabled
|
||||
|| _parsedAddressesByTagName.Values.Any(a => a.Kind == FocasAreaKind.Fixed);
|
||||
if (_fixedTreeActive)
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
@@ -306,6 +326,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_tagsByRawPath.Clear();
|
||||
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
|
||||
_parsedAddressesByTagName.Clear();
|
||||
_fixedTreeActive = false;
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
|
||||
}
|
||||
|
||||
@@ -377,7 +398,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// Fixed-tree T1 — fixed-tree references are synthesized from the cached
|
||||
// dynamic snapshot + sysinfo; no P/Invoke per Read since the poll loop
|
||||
// already fires them on cadence.
|
||||
if (_options.FixedTree.Enabled && TryReadFixedTree(reference, now) is { } fx)
|
||||
//
|
||||
// This branch matches a reference that IS a "{host}/{path}" fixed-tree key. Authored
|
||||
// tags do not take it — their reference is a RawPath — and are served further down by
|
||||
// their FIXED: address instead (Gitea #540).
|
||||
if (_fixedTreeActive && TryReadFixedTree(reference, now) is { } fx)
|
||||
{
|
||||
results[i] = fx;
|
||||
continue;
|
||||
@@ -396,8 +421,28 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = ResolveParsedAddress(def);
|
||||
|
||||
// A FIXED: tag names a field of this device's fixed-tree snapshot, which the
|
||||
// fixed-tree loop already maintains — there is no area read to issue, and no
|
||||
// connection to force. A null here means the path resolves to nothing on THIS
|
||||
// device (an unsupported capability, a renamed axis, a typo), which is a genuine
|
||||
// BadNodeIdUnknown; a device that has not answered yet reports a comms fault
|
||||
// instead, because the address may well be fine.
|
||||
if (parsed.Kind == FocasAreaKind.Fixed)
|
||||
{
|
||||
results[i] = TryReadFixedTreeOnDevice(device, parsed.FixedPath!, now)
|
||||
?? new DataValueSnapshot(
|
||||
null,
|
||||
device.LastFixedPollUtc is null
|
||||
? FocasStatusMapper.BadCommunicationError
|
||||
: FocasStatusMapper.BadNodeIdUnknown,
|
||||
null,
|
||||
now);
|
||||
continue;
|
||||
}
|
||||
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||
@@ -459,8 +504,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = ResolveParsedAddress(def);
|
||||
|
||||
// Nothing in the fixed tree is a writable location on the CNC — it is a read-out of
|
||||
// machine state (identity, positions, program, timers). Refuse before opening a
|
||||
// connection rather than sending a write the wire would reject anyway.
|
||||
if (parsed.Kind == FocasAreaKind.Fixed)
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(status);
|
||||
}
|
||||
@@ -525,31 +580,31 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// Fixed-tree T1 — Identity + Axes subtrees, populated once per session
|
||||
// from cnc_sysinfo + cnc_rdaxisname at init time and kept in DeviceState.
|
||||
if (_options.FixedTree.Enabled
|
||||
if (_fixedTreeActive
|
||||
&& _devices.TryGetValue(device.HostAddress, out var state)
|
||||
&& state.FixedTreeCache is { } cache)
|
||||
{
|
||||
var identity = deviceFolder.Folder("Identity", "Identity");
|
||||
EmitIdentityVariable(identity, device.HostAddress, "SeriesNumber", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "Version", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "MaxAxes", FocasDriverDataType.Int32);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "CncType", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "MtType", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "AxisCount", FocasDriverDataType.Int32);
|
||||
EmitIdentityVariable(identity, "SeriesNumber", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, "Version", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, "MaxAxes", FocasDriverDataType.Int32);
|
||||
EmitIdentityVariable(identity, "CncType", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, "MtType", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, "AxisCount", FocasDriverDataType.Int32);
|
||||
|
||||
var axesFolder = deviceFolder.Folder("Axes", "Axes");
|
||||
foreach (var axis in cache.Axes)
|
||||
{
|
||||
var axisFolder = axesFolder.Folder(axis.Display, axis.Display);
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "AbsolutePosition");
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "MachinePosition");
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "RelativePosition");
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "DistanceToGo");
|
||||
EmitAxisVariable(axisFolder, axis.Display, "AbsolutePosition");
|
||||
EmitAxisVariable(axisFolder, axis.Display, "MachinePosition");
|
||||
EmitAxisVariable(axisFolder, axis.Display, "RelativePosition");
|
||||
EmitAxisVariable(axisFolder, axis.Display, "DistanceToGo");
|
||||
if (cache.Capabilities.ServoLoad)
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "ServoLoad");
|
||||
EmitAxisVariable(axisFolder, axis.Display, "ServoLoad");
|
||||
}
|
||||
EmitAxisVariable(axesFolder, device.HostAddress, "FeedRate", "Actual");
|
||||
EmitAxisVariable(axesFolder, device.HostAddress, "SpindleSpeed", "Actual");
|
||||
EmitAxisVariable(axesFolder, "FeedRate", "Actual");
|
||||
EmitAxisVariable(axesFolder, "SpindleSpeed", "Actual");
|
||||
|
||||
// Spindle subtree — one folder per discovered spindle, suppressed
|
||||
// entirely on series that don't export cnc_rdspdlname. Per-spindle
|
||||
@@ -563,9 +618,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var name = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
|
||||
var spindleFolder = spindleRoot.Folder(name, name);
|
||||
if (cache.Capabilities.SpindleLoad)
|
||||
EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "Load", DriverDataType.Int32);
|
||||
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "Load", DriverDataType.Int32);
|
||||
if (cache.Capabilities.SpindleMaxRpm && i < cache.SpindleMaxRpms.Count)
|
||||
EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
|
||||
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,26 +628,26 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
if (cache.Capabilities.ProgramInfo)
|
||||
{
|
||||
var program = deviceFolder.Folder("Program", "Program");
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "Name", DriverDataType.String);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "ONumber", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "Number", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "MainNumber", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "Sequence", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "BlockCount", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, "Program", "Name", DriverDataType.String);
|
||||
EmitFixedVariable(program, "Program", "ONumber", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, "Program", "Number", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, "Program", "MainNumber", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, "Program", "Sequence", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, "Program", "BlockCount", DriverDataType.Int32);
|
||||
|
||||
var opMode = deviceFolder.Folder("OperationMode", "OperationMode");
|
||||
EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "Mode", DriverDataType.Int32);
|
||||
EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "ModeText", DriverDataType.String);
|
||||
EmitFixedVariable(opMode, "OperationMode", "Mode", DriverDataType.Int32);
|
||||
EmitFixedVariable(opMode, "OperationMode", "ModeText", DriverDataType.String);
|
||||
}
|
||||
|
||||
// Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle).
|
||||
if (cache.Capabilities.Timers)
|
||||
{
|
||||
var timers = deviceFolder.Folder("Timers", "Timers");
|
||||
EmitFixedVariable(timers, device.HostAddress, "Timers", "PowerOnSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, device.HostAddress, "Timers", "OperatingSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, device.HostAddress, "Timers", "CuttingSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, device.HostAddress, "Timers", "CycleSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, "Timers", "PowerOnSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, "Timers", "OperatingSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, "Timers", "CuttingSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, "Timers", "CycleSeconds", DriverDataType.Float64);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,10 +677,33 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
private enum FocasDriverDataType { String, Int32, Float64 }
|
||||
|
||||
/// <summary>
|
||||
/// Emit one <c>Identity</c> leaf.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The emitted <c>FullName</c> is the authorable address, not the runtime cache key.</b>
|
||||
/// A captured browse leaf's id IS its <c>FullName</c>, and that string is what both the
|
||||
/// <c>/raw</c> browse-commit mapper and the manual picker's tree-select write into the tag's
|
||||
/// <c>TagConfig.address</c>. Emitting the host-prefixed cache key therefore authored an
|
||||
/// address no FOCAS parser accepts: the driver skipped every such tag at init and each one
|
||||
/// read <c>BadNodeIdUnknown</c> forever, while the deploy sealed green and driver health
|
||||
/// stayed Healthy (Gitea #540).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The host is dropped rather than stripped later because a tag's device is already carried
|
||||
/// by its raw-tag Device row. Two routing channels for one fact is what created the defect;
|
||||
/// one channel is the fix. <see cref="FixedTreeReference"/> keeps the host for the internal
|
||||
/// per-device caches, which do need to distinguish devices.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="folder">The address-space folder to emit into.</param>
|
||||
/// <param name="field">The identity field name.</param>
|
||||
/// <param name="type">The field's data type.</param>
|
||||
private static void EmitIdentityVariable(
|
||||
IAddressSpaceBuilder folder, string deviceHost, string field, FocasDriverDataType type)
|
||||
IAddressSpaceBuilder folder, string field, FocasDriverDataType type)
|
||||
{
|
||||
var fullName = FixedTreeReference(deviceHost, $"Identity/{field}");
|
||||
var fullName = FocasAddress.ForFixedPath($"Identity/{field}");
|
||||
folder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: type switch
|
||||
@@ -643,10 +721,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
NativeDataType: NativeName(type)));
|
||||
}
|
||||
|
||||
/// <summary>Emit one <c>Axes</c> leaf. See <see cref="EmitIdentityVariable"/> on the address shape.</summary>
|
||||
/// <param name="folder">The address-space folder to emit into.</param>
|
||||
/// <param name="axisName">The axis display name.</param>
|
||||
/// <param name="field">The per-axis field name.</param>
|
||||
private static void EmitAxisVariable(
|
||||
IAddressSpaceBuilder folder, string deviceHost, string axisName, string field)
|
||||
IAddressSpaceBuilder folder, string axisName, string field)
|
||||
{
|
||||
var fullName = FixedTreeReference(deviceHost, $"Axes/{axisName}/{field}");
|
||||
var fullName = FocasAddress.ForFixedPath($"Axes/{axisName}/{field}");
|
||||
folder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: DriverDataType.Float64,
|
||||
@@ -675,13 +757,17 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
/// <summary>
|
||||
/// Emit a variable under a named fixed-tree folder (Program, OperationMode,
|
||||
/// …). Full-reference shape is <c>{deviceHost}/{folderPath}/{field}</c>.
|
||||
/// …). Address shape is <c>FIXED:{folderPath}/{field}</c> — see
|
||||
/// <see cref="EmitIdentityVariable"/>.
|
||||
/// </summary>
|
||||
/// <param name="folder">The address-space folder to emit into.</param>
|
||||
/// <param name="folderPath">The fixed-tree folder path.</param>
|
||||
/// <param name="field">The field name.</param>
|
||||
/// <param name="type">The field's data type.</param>
|
||||
private static void EmitFixedVariable(
|
||||
IAddressSpaceBuilder folder, string deviceHost, string folderPath,
|
||||
string field, DriverDataType type)
|
||||
IAddressSpaceBuilder folder, string folderPath, string field, DriverDataType type)
|
||||
{
|
||||
var fullName = FixedTreeReference(deviceHost, $"{folderPath}/{field}");
|
||||
var fullName = FocasAddress.ForFixedPath($"{folderPath}/{field}");
|
||||
folder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: type,
|
||||
@@ -700,14 +786,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical full-reference shape for a fixed-tree node. Keeps the device
|
||||
/// Canonical <b>runtime cache key</b> for a fixed-tree node. Keeps the device
|
||||
/// host as a prefix so multi-device configs don't collide, and the rest is
|
||||
/// the path inside the tree. Matches what poll-loop snapshots publish +
|
||||
/// what <see cref="ReadAsync"/> looks up.
|
||||
/// what <see cref="TryReadFixedTreeOnDevice"/> looks up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>This is an internal key, not an authorable address</b> — see
|
||||
/// <see cref="EmitIdentityVariable"/> for why the two are deliberately different strings
|
||||
/// (Gitea #540).
|
||||
/// </remarks>
|
||||
/// <param name="deviceHost">The host address of the device.</param>
|
||||
/// <param name="path">The path within the fixed tree.</param>
|
||||
/// <returns>The canonical full reference for the node.</returns>
|
||||
/// <returns>The canonical cache key for the node.</returns>
|
||||
internal static string FixedTreeReference(string deviceHost, string path) =>
|
||||
$"{deviceHost}/{path}";
|
||||
|
||||
@@ -823,6 +914,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
state.FixedTreeCache = new FocasFixedTreeCache(
|
||||
sys, [.. axes], [.. spindles], [.. spindleMaxRpms], 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.
|
||||
state.LastFixedPollUtc = DateTime.UtcNow;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||
catch (Exception ex)
|
||||
@@ -872,6 +967,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
if (i == 0) { firstAxisSnap = snap; PublishRateSnapshot(state, snap); }
|
||||
}
|
||||
|
||||
// The device answered this tick. Stamped after the axis pass rather than at the top
|
||||
// of the loop: EnsureConnectedAsync can hand back a cached handle without any wire
|
||||
// traffic, so it alone does not prove the CNC is still there.
|
||||
state.LastFixedPollUtc = DateTime.UtcNow;
|
||||
|
||||
// Servo loads + spindle loads — both return bulk arrays, so folding
|
||||
// into the axis cadence is cheap. Each is gated by the bootstrap
|
||||
// capability probe — unsupported on this series = silent skip.
|
||||
@@ -1054,79 +1154,119 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
if (!reference.StartsWith(state.Options.HostAddress + "/", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (state.LastFixedSnapshots.TryGetValue(reference, out var raw))
|
||||
return new DataValueSnapshot(raw, FocasStatusMapper.Good, now, now);
|
||||
if (TryReadFixedTreeOnDevice(state, reference[(state.Options.HostAddress.Length + 1)..], now) is { } hit)
|
||||
return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Servo-load match: reference shape is "{host}/Axes/{name}/ServoLoad"
|
||||
var suffixFull = reference[(state.Options.HostAddress.Length + 1)..];
|
||||
if (suffixFull.StartsWith("Axes/", StringComparison.Ordinal) && suffixFull.EndsWith("/ServoLoad", StringComparison.Ordinal))
|
||||
{
|
||||
var axisName = suffixFull["Axes/".Length..^"/ServoLoad".Length];
|
||||
if (state.LastServoLoads.TryGetValue(axisName, out var load))
|
||||
return new DataValueSnapshot(load, FocasStatusMapper.Good, now, now);
|
||||
}
|
||||
/// <summary>
|
||||
/// How long after a device's last answered fixed-tree poll its cached values stop being
|
||||
/// served as Good. Eight axis-poll cadences, floored at 30 s so the sub-second default
|
||||
/// (250 ms) does not make an ordinarily slow CNC flap.
|
||||
/// </summary>
|
||||
/// <param name="pollInterval">The configured fixed-tree poll interval.</param>
|
||||
/// <returns>The staleness budget.</returns>
|
||||
internal static TimeSpan FixedTreeStaleAfter(TimeSpan pollInterval) =>
|
||||
TimeSpan.FromTicks(Math.Max(pollInterval.Ticks * 8, TimeSpan.FromSeconds(30).Ticks));
|
||||
|
||||
// Spindle matches: "{host}/Spindle/{name}/Load" + "{host}/Spindle/{name}/MaxRpm"
|
||||
if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
|
||||
&& state.FixedTreeCache is { } spindleCache)
|
||||
/// <summary>
|
||||
/// Resolves one fixed-tree <paramref name="path"/> (<c>Identity/SeriesNumber</c>,
|
||||
/// <c>Axes/X/AbsolutePosition</c>, …) against a single device's caches. Returns null when
|
||||
/// this device's fixed tree has no such path — the caller decides what that means.
|
||||
/// </summary>
|
||||
/// <param name="state">The device whose caches to read.</param>
|
||||
/// <param name="path">The fixed-tree path, with no device prefix.</param>
|
||||
/// <param name="now">The read timestamp.</param>
|
||||
/// <returns>The snapshot, or null when the path names nothing on this device.</returns>
|
||||
/// <remarks>
|
||||
/// Values are dated from <see cref="DeviceState.LastFixedPollUtc"/>, not from
|
||||
/// <paramref name="now"/>, and go <c>BadCommunicationError</c> once that stamp is older than
|
||||
/// <see cref="FixedTreeStaleAfter"/>. A cache hit deliberately does NOT advance driver health:
|
||||
/// the value came from the poll loop, not from this call, and crediting it would let a driver
|
||||
/// serving nothing but fixed-tree tags report a fresh successful read while its CNC was gone.
|
||||
/// </remarks>
|
||||
private DataValueSnapshot? TryReadFixedTreeOnDevice(DeviceState state, string path, DateTime now)
|
||||
{
|
||||
var polledUtc = state.LastFixedPollUtc;
|
||||
if (polledUtc is null) return null;
|
||||
var status = now - polledUtc.Value > FixedTreeStaleAfter(_options.FixedTree.PollInterval)
|
||||
? FocasStatusMapper.BadCommunicationError
|
||||
: FocasStatusMapper.Good;
|
||||
var sourceUtc = polledUtc.Value;
|
||||
|
||||
if (state.LastFixedSnapshots.TryGetValue(
|
||||
FixedTreeReference(state.Options.HostAddress, path), out var raw))
|
||||
return new DataValueSnapshot(raw, status, sourceUtc, now);
|
||||
|
||||
// Servo-load match: path shape is "Axes/{name}/ServoLoad"
|
||||
var suffixFull = path;
|
||||
if (suffixFull.StartsWith("Axes/", StringComparison.Ordinal) && suffixFull.EndsWith("/ServoLoad", StringComparison.Ordinal))
|
||||
{
|
||||
var axisName = suffixFull["Axes/".Length..^"/ServoLoad".Length];
|
||||
if (state.LastServoLoads.TryGetValue(axisName, out var load))
|
||||
return new DataValueSnapshot(load, status, sourceUtc, now);
|
||||
}
|
||||
|
||||
// Spindle matches: "Spindle/{name}/Load" + "Spindle/{name}/MaxRpm"
|
||||
if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
|
||||
&& state.FixedTreeCache is { } spindleCache)
|
||||
{
|
||||
var tail = suffixFull["Spindle/".Length..];
|
||||
var slash = tail.IndexOf('/');
|
||||
if (slash > 0)
|
||||
{
|
||||
var tail = suffixFull["Spindle/".Length..];
|
||||
var slash = tail.IndexOf('/');
|
||||
if (slash > 0)
|
||||
var spindleName = tail[..slash];
|
||||
var field = tail[(slash + 1)..];
|
||||
var idx = -1;
|
||||
for (var i = 0; i < spindleCache.Spindles.Count; i++)
|
||||
{
|
||||
var spindleName = tail[..slash];
|
||||
var field = tail[(slash + 1)..];
|
||||
var idx = -1;
|
||||
for (var i = 0; i < spindleCache.Spindles.Count; i++)
|
||||
var s = spindleCache.Spindles[i];
|
||||
var display = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
|
||||
if (string.Equals(display, spindleName, StringComparison.OrdinalIgnoreCase)) { idx = i; break; }
|
||||
}
|
||||
if (idx >= 0)
|
||||
{
|
||||
object? value = field switch
|
||||
{
|
||||
var s = spindleCache.Spindles[i];
|
||||
var display = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
|
||||
if (string.Equals(display, spindleName, StringComparison.OrdinalIgnoreCase)) { idx = i; break; }
|
||||
}
|
||||
if (idx >= 0)
|
||||
{
|
||||
object? value = field switch
|
||||
{
|
||||
"Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
|
||||
"MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
|
||||
_ => null,
|
||||
};
|
||||
if (value is not null)
|
||||
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
|
||||
}
|
||||
"Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
|
||||
"MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
|
||||
_ => null,
|
||||
};
|
||||
if (value is not null)
|
||||
return new DataValueSnapshot(value, status, sourceUtc, now);
|
||||
}
|
||||
}
|
||||
// Identity strings + program / op-mode fields aren't cached as doubles —
|
||||
// re-derive from the struct caches.
|
||||
if (state.FixedTreeCache is { } cache)
|
||||
}
|
||||
// Identity strings + program / op-mode fields aren't cached as doubles —
|
||||
// re-derive from the struct caches.
|
||||
if (state.FixedTreeCache is { } cache)
|
||||
{
|
||||
var value = path switch
|
||||
{
|
||||
var suffix = reference[(state.Options.HostAddress.Length + 1)..];
|
||||
var value = suffix switch
|
||||
{
|
||||
"Identity/SeriesNumber" => (object)cache.SysInfo.Series,
|
||||
"Identity/Version" => cache.SysInfo.Version,
|
||||
"Identity/MaxAxes" => cache.SysInfo.MaxAxis,
|
||||
"Identity/CncType" => cache.SysInfo.CncType,
|
||||
"Identity/MtType" => cache.SysInfo.MtType,
|
||||
"Identity/AxisCount" => cache.SysInfo.AxesCount,
|
||||
"Program/Name" => (object?)state.LastProgramInfo?.Name,
|
||||
"Program/ONumber" => state.LastProgramInfo?.ONumber,
|
||||
"Program/BlockCount" => state.LastProgramInfo?.BlockCount,
|
||||
"Program/Number" => state.LastProgramAxisRef?.ProgramNumber,
|
||||
"Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber,
|
||||
"Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber,
|
||||
"OperationMode/Mode" => state.LastProgramInfo?.Mode,
|
||||
"OperationMode/ModeText" => state.LastProgramInfo is { } pi
|
||||
? FocasOpMode.ToText(pi.Mode) : null,
|
||||
"Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
|
||||
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
|
||||
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
|
||||
"Timers/CycleSeconds" => TimerValue(state, FocasTimerKind.Cycle),
|
||||
_ => null,
|
||||
};
|
||||
if (value is not null)
|
||||
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
|
||||
}
|
||||
"Identity/SeriesNumber" => (object)cache.SysInfo.Series,
|
||||
"Identity/Version" => cache.SysInfo.Version,
|
||||
"Identity/MaxAxes" => cache.SysInfo.MaxAxis,
|
||||
"Identity/CncType" => cache.SysInfo.CncType,
|
||||
"Identity/MtType" => cache.SysInfo.MtType,
|
||||
"Identity/AxisCount" => cache.SysInfo.AxesCount,
|
||||
"Program/Name" => (object?)state.LastProgramInfo?.Name,
|
||||
"Program/ONumber" => state.LastProgramInfo?.ONumber,
|
||||
"Program/BlockCount" => state.LastProgramInfo?.BlockCount,
|
||||
"Program/Number" => state.LastProgramAxisRef?.ProgramNumber,
|
||||
"Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber,
|
||||
"Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber,
|
||||
"OperationMode/Mode" => state.LastProgramInfo?.Mode,
|
||||
"OperationMode/ModeText" => state.LastProgramInfo is { } pi
|
||||
? FocasOpMode.ToText(pi.Mode) : null,
|
||||
"Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
|
||||
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
|
||||
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
|
||||
"Timers/CycleSeconds" => TimerValue(state, FocasTimerKind.Cycle),
|
||||
_ => null,
|
||||
};
|
||||
if (value is not null)
|
||||
return new DataValueSnapshot(value, status, sourceUtc, now);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1357,6 +1497,21 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public CancellationTokenSource? FixedTreeCts { get; set; }
|
||||
/// <summary>Gets or sets the fixed-tree cache for this device.</summary>
|
||||
public FocasFixedTreeCache? FixedTreeCache { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC time the fixed-tree loop last got an answer out of this device
|
||||
/// — set when the bootstrap completes and again after each poll tick's axis pass. Null
|
||||
/// until the device has answered once.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>This is what stops a dead CNC publishing its last values as Good forever.</b> The
|
||||
/// fixed-tree poll loop swallows a failed tick (LogDebug + retry) by design, so a device
|
||||
/// that stops answering leaves the caches populated and unchanging with nothing marking
|
||||
/// them stale. That was harmless while nothing could bind to the fixed tree; Gitea #540
|
||||
/// makes those nodes authorable, so reads are now dated from this stamp and go Bad once
|
||||
/// it falls outside <see cref="FixedTreeStaleAfter"/>.
|
||||
/// </remarks>
|
||||
public DateTime? LastFixedPollUtc { get; set; }
|
||||
/// <summary>
|
||||
/// Gets the last fixed-tree snapshots by field name. Double-typed so axis
|
||||
/// positions can carry the <c>10^PositionDecimalPlaces</c> engineering-unit
|
||||
|
||||
Reference in New Issue
Block a user