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
|
||||
|
||||
+60
-20
@@ -1,9 +1,16 @@
|
||||
@* FOCAS address picker:
|
||||
1. Static parameter group + parameter ID → axis:5 (always available).
|
||||
1. Manual composition of the three addressed spaces — PMC (X0.0 / R100), CNC parameter
|
||||
(PARAM:1020) and macro variable (MACRO:500) — always available.
|
||||
2. (DriverOperator-gated, when the driver supports online discovery) Browse the FixedTree
|
||||
device model captured by the universal DiscoveryDriverBrowser. FOCAS opens take a few
|
||||
seconds longer than other drivers — its FixedTree cache fills post-connect and the browser
|
||||
runs an UntilStable settle loop; the _opening spinner covers the wait. *@
|
||||
device model captured by the universal DiscoveryDriverBrowser, which yields FIXED: paths.
|
||||
FOCAS opens take a few seconds longer than other drivers — its FixedTree cache fills
|
||||
post-connect and the browser runs an UntilStable settle loop; the _opening spinner covers
|
||||
the wait.
|
||||
|
||||
Both branches now emit an address FocasAddress.TryParse accepts. Branch 1 used to offer
|
||||
axis/spindle/program/status "parameter groups", which are not FOCAS addressing spaces, and
|
||||
emitted "axis:5"; branch 2 committed the browse leaf's host-prefixed full reference. Neither
|
||||
could be read (Gitea #540). *@
|
||||
@implements IAsyncDisposable
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
|
||||
@@ -14,20 +21,43 @@
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Parameter group</label>
|
||||
<select class="form-select form-select-sm" @bind="_group" @bind:after="OnChangedAsync">
|
||||
<option value="axis">axis</option>
|
||||
<option value="spindle">spindle</option>
|
||||
<option value="program">program</option>
|
||||
<option value="status">status</option>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Address space</label>
|
||||
<select class="form-select form-select-sm" @bind="_kind" @bind:after="OnChangedAsync">
|
||||
<option value="@FocasManualAddressKind.Pmc">PMC</option>
|
||||
<option value="@FocasManualAddressKind.Parameter">CNC parameter</option>
|
||||
<option value="@FocasManualAddressKind.Macro">Macro variable</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (_kind == FocasManualAddressKind.Pmc)
|
||||
{
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Area</label>
|
||||
<select class="form-select form-select-sm" @bind="_pmcLetter" @bind:after="OnChangedAsync">
|
||||
@foreach (var letter in FocasAddressBuilder.PmcLetters)
|
||||
{
|
||||
<option value="@letter">@letter</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Parameter ID</label>
|
||||
<label class="form-label">@(_kind == FocasManualAddressKind.Pmc ? "Byte" : "Number")</label>
|
||||
<input type="number" class="form-control form-control-sm" min="0"
|
||||
@bind="_parameterId" @bind:after="OnChangedAsync" />
|
||||
@bind="_number" @bind:after="OnChangedAsync" />
|
||||
</div>
|
||||
@if (_kind != FocasManualAddressKind.Macro)
|
||||
{
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">
|
||||
Bit <span class="text-muted small">(optional, @(_kind == FocasManualAddressKind.Pmc ? "0-7" : "0-31"))</span>
|
||||
</label>
|
||||
<input type="number" class="form-control form-control-sm" min="0"
|
||||
max="@(_kind == FocasManualAddressKind.Pmc ? 7 : 31)"
|
||||
placeholder="whole word"
|
||||
@bind="_bitIndex" @bind:after="OnChangedAsync" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_canBrowse)
|
||||
@@ -68,7 +98,14 @@
|
||||
|
||||
<div class="mt-3">
|
||||
<span class="text-muted small">Result:</span>
|
||||
<code class="mono ms-2">@_built</code>
|
||||
@if (string.IsNullOrEmpty(_built))
|
||||
{
|
||||
<span class="chip chip-bad ms-2">not a valid address</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<code class="mono ms-2">@_built</code>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
@@ -79,8 +116,10 @@
|
||||
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
|
||||
[Parameter] public Func<string> GetConfigJson { get; set; } = () => "{}";
|
||||
|
||||
private string _group = "axis";
|
||||
private int _parameterId = 0;
|
||||
private FocasManualAddressKind _kind = FocasManualAddressKind.Pmc;
|
||||
private string _pmcLetter = "R";
|
||||
private int _number = 0;
|
||||
private int? _bitIndex;
|
||||
private string _built = "";
|
||||
|
||||
private Guid _token = Guid.Empty;
|
||||
@@ -94,7 +133,7 @@
|
||||
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
|
||||
// CanBrowse constructs a throwaway driver — evaluate once, not per render.
|
||||
_canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
|
||||
_built = FocasAddressBuilder.Build(_group, _parameterId);
|
||||
_built = FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
|
||||
await CurrentAddressChanged.InvokeAsync(_built);
|
||||
}
|
||||
|
||||
@@ -125,8 +164,9 @@
|
||||
|
||||
private async Task OnTreeSelectAsync(BrowseNode node)
|
||||
{
|
||||
// Folders fire the same callback — commit only on a leaf. The captured NodeId is the
|
||||
// driver FullName (FixedTree address), which the group/id builder cannot reconstruct, so
|
||||
// Folders fire the same callback — commit only on a leaf. The captured NodeId is the driver
|
||||
// FullName, which for a fixed-tree leaf is its canonical FIXED: address (Gitea #540); the
|
||||
// manual builder composes only the three addressed spaces and cannot reconstruct it, so
|
||||
// commit it directly rather than through OnChangedAsync.
|
||||
if (node.Kind != BrowseNodeKind.Leaf) return;
|
||||
_built = node.NodeId;
|
||||
@@ -135,7 +175,7 @@
|
||||
|
||||
private async Task OnChangedAsync()
|
||||
{
|
||||
_built = FocasAddressBuilder.Build(_group, _parameterId);
|
||||
_built = FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
|
||||
await CurrentAddressChanged.InvokeAsync(_built);
|
||||
}
|
||||
|
||||
|
||||
+68
-8
@@ -1,16 +1,76 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
|
||||
|
||||
/// <summary>
|
||||
/// Pure static helper that converts a FOCAS parameter group + parameter ID
|
||||
/// into the canonical address string (e.g. axis:5).
|
||||
/// The addressing spaces the manual FOCAS picker can compose. Mirrors the parseable subset of
|
||||
/// <see cref="FocasAreaKind"/>: <see cref="FocasAreaKind.Fixed"/> is deliberately absent because a
|
||||
/// fixed-tree path is discovered from the machine, not composed from parts — the picker's browse
|
||||
/// tree supplies those.
|
||||
/// </summary>
|
||||
public enum FocasManualAddressKind
|
||||
{
|
||||
/// <summary>PMC area — letter + byte + optional bit.</summary>
|
||||
Pmc,
|
||||
|
||||
/// <summary>CNC parameter number + optional bit.</summary>
|
||||
Parameter,
|
||||
|
||||
/// <summary>Macro variable number.</summary>
|
||||
Macro,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure static helper that composes a canonical FOCAS address from the manual picker's parts.
|
||||
/// Extracted so unit tests can call it without bUnit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>It builds through <see cref="FocasAddress"/> rather than formatting a string</b>, so what
|
||||
/// it emits is parseable by construction and the two cannot drift.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It previously emitted <c>$"{group}:{parameterId}"</c> over a group list of
|
||||
/// <c>axis/spindle/program/status</c> — none of which is a FOCAS addressing space — so the
|
||||
/// picker's own default value, <c>axis:5</c>, was an address the driver rejects. Together with
|
||||
/// the browse-commit defect this meant every UI-assisted authoring path produced unusable tags,
|
||||
/// and only hand-typed addresses worked at all (Gitea #540).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class FocasAddressBuilder
|
||||
{
|
||||
/// <summary>Builds the canonical FOCAS address string from a parameter group and parameter ID.</summary>
|
||||
/// <param name="group">The FOCAS parameter group (e.g. <c>"axis"</c>).</param>
|
||||
/// <param name="parameterId">The parameter ID within the group.</param>
|
||||
/// <returns>The canonical address string in <c>group:parameterId</c> form (e.g. <c>axis:5</c>).</returns>
|
||||
public static string Build(string group, int parameterId)
|
||||
=> $"{group}:{parameterId}";
|
||||
/// <summary>The PMC area letters the driver accepts, in the order the picker offers them.</summary>
|
||||
public static IReadOnlyList<string> PmcLetters { get; } =
|
||||
["X", "Y", "F", "G", "R", "D", "C", "K", "A", "E", "T"];
|
||||
|
||||
/// <summary>
|
||||
/// Composes the canonical FOCAS address string, or an empty string when the parts do not form a
|
||||
/// valid address (an out-of-range bit, a negative number, an unknown PMC letter).
|
||||
/// </summary>
|
||||
/// <param name="kind">Which addressing space the address is in.</param>
|
||||
/// <param name="pmcLetter">The PMC area letter; ignored unless <paramref name="kind"/> is
|
||||
/// <see cref="FocasManualAddressKind.Pmc"/>.</param>
|
||||
/// <param name="number">The PMC byte, parameter number or macro number.</param>
|
||||
/// <param name="bitIndex">Optional bit index — 0–7 for PMC, 0–31 for a parameter. Null for a
|
||||
/// whole-word address; always ignored for a macro, which has no bit form.</param>
|
||||
/// <returns>The canonical address, or an empty string when the parts are not addressable.</returns>
|
||||
public static string Build(
|
||||
FocasManualAddressKind kind, string? pmcLetter, int number, int? bitIndex)
|
||||
{
|
||||
FocasAddress? candidate = kind switch
|
||||
{
|
||||
FocasManualAddressKind.Pmc => new FocasAddress(
|
||||
FocasAreaKind.Pmc, (pmcLetter ?? "").Trim().ToUpperInvariant(), number, bitIndex),
|
||||
FocasManualAddressKind.Parameter => new FocasAddress(
|
||||
FocasAreaKind.Parameter, null, number, bitIndex),
|
||||
FocasManualAddressKind.Macro => new FocasAddress(
|
||||
FocasAreaKind.Macro, null, number, null),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// Round-trip rather than trust the parts: the record accepts any values, the parser is the
|
||||
// authority on which of them address anything, and an empty result is what the picker shows
|
||||
// as "no address yet" instead of a plausible-looking string the driver would silently skip.
|
||||
return candidate?.Canonical is { } text && FocasAddress.TryParse(text) is not null ? text : "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,32 @@ public sealed class FocasTagConfigModel
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
|
||||
/// <remarks>
|
||||
/// <b>This runs the driver's real parser, not a non-empty check</b> (Gitea #540). It used to
|
||||
/// accept any non-blank string, which is how 20 tags carrying a browse full reference
|
||||
/// (<c>10.201.31.5:8193/Identity/SeriesNumber</c>) were authored, deployed and sealed green —
|
||||
/// the driver then skipped every one at init and they read <c>BadNodeIdUnknown</c> forever
|
||||
/// with no signal anywhere. An address that cannot be parsed cannot be polled, so the
|
||||
/// rejection belongs here, where the operator is still looking at the field.
|
||||
/// <para><see cref="FocasAddress"/> lives in the driver's <c>Contracts</c> assembly precisely
|
||||
/// so this project can call it without referencing the runtime driver (Gitea #526).</para>
|
||||
/// </remarks>
|
||||
public string? Validate()
|
||||
=> string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Address)) return "Address is required.";
|
||||
return FocasAddress.TryParse(Address) is null ? DescribeUnparseableAddress(Address) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The operator-facing rejection message for an address the FOCAS parser will not accept,
|
||||
/// naming the four legal forms. Shared with the deploy-time inspector so the modal and the
|
||||
/// deploy gate say the same thing about the same string.
|
||||
/// </summary>
|
||||
/// <param name="address">The rejected address.</param>
|
||||
/// <returns>The message.</returns>
|
||||
internal static string DescribeUnparseableAddress(string address) =>
|
||||
$"'{address.Trim()}' is not a FOCAS address. Expected a PMC address (X0.0, R100, F20.3), " +
|
||||
"a CNC parameter (PARAM:1020, PARAM:1815/0), a macro variable (MACRO:500), or a fixed-tree " +
|
||||
"path (FIXED:Identity/SeriesNumber). A browse full reference including the device host is " +
|
||||
"not an address — commit the leaf from the browser rather than pasting its id.";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user