merge: FOCAS fixed-tree tags are authorable and readable (#540); skipped-tag invisibility split to #541
This commit is contained in:
+53
-5
@@ -87,12 +87,33 @@ on.
|
||||
| `X0.0` / `R100` / `R100.3` | PMC bit or byte | Letter + number; optional `.bit` for bit access |
|
||||
| `PARAM:1815` / `PARAM:1815/0` | CNC parameter | Number + optional axis index |
|
||||
| `MACRO:500` | Custom macro variable | System / user macro variable number |
|
||||
| `FIXED:Identity/SeriesNumber` | Fixed-tree field | A path into the [fixed node tree](#fixed-node-tree) — read-only |
|
||||
|
||||
The first three name a location the driver asks the CNC for on demand.
|
||||
`FIXED:` does not: it names a field of the per-device fixed-tree snapshot the
|
||||
driver's own poll loop already maintains, so a `FIXED:` read issues no wire
|
||||
traffic. 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.
|
||||
|
||||
**An address never carries the device.** A tag's device comes from its raw-tag
|
||||
Device row; an address that also named a host would be a second, conflicting
|
||||
routing channel. This is worth stating because it is exactly what went wrong in
|
||||
[#540](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/540): browse leaves
|
||||
advertised `{deviceHost}/{path}` as their reference, the browse-commit wrote that
|
||||
into the address, and every committed tag read `BadNodeIdUnknown` forever.
|
||||
|
||||
Addresses are validated against the per-device `Series` at `InitializeAsync` —
|
||||
a config referencing a number outside the documented range for that series
|
||||
fails at load time with an error message naming the limit. See
|
||||
[`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md) for the
|
||||
authoritative range table.
|
||||
authoritative range table. (`FIXED:` paths have no numeric range to check; a
|
||||
path the machine does not expose reads `BadNodeIdUnknown`.)
|
||||
|
||||
An address the parser cannot read is rejected **when you author it** — by the
|
||||
`/raw` tag modal, and by the deploy-time inspector for config arriving via CSV
|
||||
import or the API. It is not a warning you can deploy past into a driver that
|
||||
skips the tag at startup.
|
||||
|
||||
## Backend selection
|
||||
|
||||
@@ -150,7 +171,34 @@ timers without declaring per-address tags.
|
||||
}
|
||||
```
|
||||
|
||||
What gets populated (all under `FOCAS/{deviceHostAddress}/`):
|
||||
`Enabled` defaults to **false** so an upgrade does not grow nodes on configs
|
||||
that never asked for them. You do not have to set it to use the tree: **authoring
|
||||
any `FIXED:` tag arms the poll loop by itself**, because committing those tags
|
||||
already says you want it. The flag remains the way to publish the tree without
|
||||
authoring anything (and the browser turns it on for the duration of a browse).
|
||||
|
||||
### Authoring fixed-tree tags
|
||||
|
||||
Browse the device (`/raw` → device → **Browse device…**, or the tag modal's
|
||||
**Browse machine**) and commit the leaves you want. Each leaf carries its own
|
||||
`FIXED:` address, so the commit produces working tags with no editing. The
|
||||
manual composer next to the browser covers PMC / `PARAM:` / `MACRO:` only —
|
||||
fixed-tree paths are discovered from the machine, not composed from parts.
|
||||
|
||||
Two caveats worth knowing before you browse:
|
||||
|
||||
- **A real CNC can report duplicate sibling names.** One observed Makino returns
|
||||
two axis folders both called `AA`. Committing both is refused at commit time
|
||||
(they would be the same RawPath), and the runtime cache is keyed by the same
|
||||
name — so the `Axes/` subtree on such a machine cannot currently distinguish
|
||||
them. Prefer the non-axis subtrees there until this is resolved.
|
||||
- **Values are dated from the last answered poll, not from the read.** If a CNC
|
||||
stops answering, its fixed-tree values go `BadCommunicationError` rather than
|
||||
continuing to serve the last snapshot as `Good`; the budget is eight poll
|
||||
cadences, floored at 30 s.
|
||||
|
||||
What gets populated (all under `FOCAS/{deviceHostAddress}/`, addressed as
|
||||
`FIXED:{subtree}/{field}`):
|
||||
|
||||
| Subtree | Nodes | Source call |
|
||||
|---------|-------|-------------|
|
||||
@@ -195,9 +243,9 @@ The driver now attempts to auto-scale each axis position using the CNC's `cnc_ge
|
||||
- No upper-bound clamp on the figure magnitude returned by the CNC — a misbehaving figure could overflow `Math.Pow`; a max-figure guard should be added.
|
||||
- The negative-figure branch (falls back to manual `PositionDecimalPlaces`) is handled but not explicitly unit-tested.
|
||||
|
||||
**Still user-authored**: `PARAM:6711`, `MACRO:500`, `R100` etc. — specific
|
||||
numbers whose meaning is MTB-specific. Those go under the device folder
|
||||
alongside the fixed subtree.
|
||||
**Still hand-authored**: `PARAM:6711`, `MACRO:500`, `R100` etc. — specific
|
||||
numbers whose meaning is MTB-specific, and which no browse can discover. Those
|
||||
go under the device folder alongside the fixed subtree.
|
||||
|
||||
## Alarm projection
|
||||
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>FIXED:</c> address form added by Gitea #540, and the guarantee that adding it did not
|
||||
/// disturb the three forms that were already there.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasAddressFixedFormTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("FIXED:Identity/SeriesNumber", "Identity/SeriesNumber")]
|
||||
[InlineData("FIXED:Axes/X/AbsolutePosition", "Axes/X/AbsolutePosition")]
|
||||
[InlineData("FIXED:Timers/PowerOnSeconds", "Timers/PowerOnSeconds")]
|
||||
[InlineData("FIXED:Spindle/S1/Load", "Spindle/S1/Load")]
|
||||
[InlineData("fixed:Program/Name", "Program/Name")] // prefix is case-insensitive
|
||||
[InlineData(" FIXED:Program/Name ", "Program/Name")] // outer whitespace tolerated
|
||||
public void Parses_a_fixed_tree_path(string input, string expectedPath)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldBe(FocasAreaKind.Fixed);
|
||||
parsed.FixedPath.ShouldBe(expectedPath);
|
||||
parsed.Canonical.ShouldBe("FIXED:" + expectedPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("FIXED:")] // no path
|
||||
[InlineData("FIXED: ")]
|
||||
[InlineData("FIXED:/Identity/Series")] // leading separator
|
||||
[InlineData("FIXED:Identity/Series/")] // trailing separator
|
||||
[InlineData("FIXED:Identity//Series")] // empty segment
|
||||
[InlineData("FIXED:Identity/ /Series")] // blank segment
|
||||
[InlineData("FIXED:MACRO:500")] // a colon would make the grammar ambiguous
|
||||
public void Rejects_a_path_that_is_not_addressable(string input)
|
||||
=> FocasAddress.TryParse(input).ShouldBeNull();
|
||||
|
||||
/// <summary>
|
||||
/// The exact strings the defect produced. Each names a real fixed-tree field but carries the
|
||||
/// device host, which is the tag's Device row's job — so they must still be rejected rather
|
||||
/// than quietly accepted with the host treated as a path segment.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("10.201.31.5:8193/Identity/SeriesNumber")]
|
||||
[InlineData("focas://10.201.31.5:8193/Axes/X/AbsolutePosition")]
|
||||
[InlineData("axis:5")] // the manual picker's old default
|
||||
public void Still_rejects_what_the_defect_authored(string input)
|
||||
=> FocasAddress.TryParse(input).ShouldBeNull();
|
||||
|
||||
/// <summary>
|
||||
/// <c>F</c> is a valid PMC area letter, so the <c>FIXED:</c> prefix has to be tested before
|
||||
/// the PMC branch. Both forms must survive that ordering.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_fixed_prefix_does_not_shadow_the_pmc_F_area()
|
||||
{
|
||||
FocasAddress.TryParse("F20.3")!.Kind.ShouldBe(FocasAreaKind.Pmc);
|
||||
FocasAddress.TryParse("F20.3")!.PmcLetter.ShouldBe("F");
|
||||
FocasAddress.TryParse("FIXED:Program/Name")!.Kind.ShouldBe(FocasAreaKind.Fixed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("R100")]
|
||||
[InlineData("X0.0")]
|
||||
[InlineData("PARAM:1020")]
|
||||
[InlineData("PARAM:1815/0")]
|
||||
[InlineData("MACRO:500")]
|
||||
public void The_pre_existing_forms_still_round_trip(string input)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldNotBe(FocasAreaKind.Fixed);
|
||||
parsed.Canonical.ShouldBe(input);
|
||||
}
|
||||
|
||||
/// <summary>The one place the <c>FIXED:</c> spelling is built, so emitter and parser cannot drift.</summary>
|
||||
[Fact]
|
||||
public void ForFixedPath_produces_what_TryParse_accepts()
|
||||
{
|
||||
var built = FocasAddress.ForFixedPath("Identity/SeriesNumber");
|
||||
|
||||
built.ShouldBe("FIXED:Identity/SeriesNumber");
|
||||
FocasAddress.TryParse(built)!.FixedPath.ShouldBe("Identity/SeriesNumber");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #540 — authoring a fixed-tree tag through <c>/raw</c> → Browse device… produced a tag
|
||||
/// the driver could never read. The browse leaf's full reference was <c>{deviceHost}/{path}</c>,
|
||||
/// the commit mapper wrote it verbatim into <c>TagConfig.address</c>, and
|
||||
/// <see cref="FocasAddress.TryParse"/> accepts no such form — so the driver skipped the tag at
|
||||
/// init and every read returned <c>BadNodeIdUnknown</c>, indefinitely, while the deploy sealed
|
||||
/// green and driver health stayed Healthy with zero errors.
|
||||
/// <para>
|
||||
/// Found on a real Makino Pro 5 / Fanuc 30i: 0 of 20 browse-committed tags read, while two
|
||||
/// hand-authored <c>MACRO:</c> tags on the same device read Good.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasFixedTreeAddressingTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
private static FocasDriver NewDriver(
|
||||
FakeFocasClientFactory factory,
|
||||
bool fixedTreeEnabled,
|
||||
params FocasTagDefinition[] tags) =>
|
||||
new(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
FixedTree = new FocasFixedTreeOptions { Enabled = fixedTreeEnabled },
|
||||
RawTags = FocasRawTags.Entries(tags),
|
||||
}, "drv-1", factory);
|
||||
|
||||
private static async Task<bool> WaitForAsync(Func<bool> predicate, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (predicate()) return true;
|
||||
await Task.Delay(25);
|
||||
}
|
||||
return predicate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The headline regression, stated over the WHOLE emitted tree rather than a sample: nothing
|
||||
/// the browser can offer is unauthorable. Sampling is what let this ship — the tree looked
|
||||
/// right, and no test ever asked whether the strings hanging off it were addresses.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Every_browse_emitted_leaf_carries_an_address_the_driver_can_parse()
|
||||
{
|
||||
var drv = NewDriver(new FakeFocasClientFactory(), fixedTreeEnabled: true);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await WaitForAsync(
|
||||
() => drv.GetDeviceState(Host)?.FixedTreeCache is not null, TimeSpan.FromSeconds(5)))
|
||||
.ShouldBeTrue("the fixed-tree cache never filled, so the tree under test was empty");
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.ShouldNotBeEmpty();
|
||||
foreach (var (browseName, info) in builder.Variables)
|
||||
{
|
||||
FocasAddress.TryParse(info.FullName)
|
||||
.ShouldNotBeNull($"leaf '{browseName}' emitted '{info.FullName}', which is not an address");
|
||||
// The specific shape that broke it: the device host leaking into the address, where it
|
||||
// is both unparseable and a second routing channel competing with the tag's Device row.
|
||||
info.FullName.ShouldNotContain("10.0.0.5");
|
||||
}
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The end-to-end claim the issue is about: take what the browser offers, author it as a tag,
|
||||
/// and read it. Anything short of this passed before the fix.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_leaf_authored_from_the_browse_tree_reads_its_value()
|
||||
{
|
||||
// 1. Browse.
|
||||
var browseDrv = NewDriver(new FakeFocasClientFactory(), fixedTreeEnabled: true);
|
||||
await browseDrv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await WaitForAsync(
|
||||
() => browseDrv.GetDeviceState(Host)?.FixedTreeCache is not null, TimeSpan.FromSeconds(5)))
|
||||
.ShouldBeTrue();
|
||||
var builder = new RecordingBuilder();
|
||||
await browseDrv.DiscoverAsync(builder, CancellationToken.None);
|
||||
await browseDrv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
var seriesLeaf = builder.Variables.Single(v => v.Info.FullName == "FIXED:Identity/SeriesNumber");
|
||||
|
||||
// 2. Commit it as a raw tag, exactly as the browse-commit mapper does — the leaf's full
|
||||
// reference becomes the tag's address, and its RawPath is where the operator put it.
|
||||
var runDrv = NewDriver(
|
||||
new FakeFocasClientFactory(),
|
||||
fixedTreeEnabled: true,
|
||||
new FocasTagDefinition(
|
||||
"focas-cnc/cnc/FOCAS/cnc/Identity/SeriesNumber",
|
||||
Host,
|
||||
seriesLeaf.Info.FullName,
|
||||
FocasDataType.String,
|
||||
Writable: false));
|
||||
await runDrv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await WaitForAsync(
|
||||
() => runDrv.GetDeviceState(Host)?.FixedTreeCache is not null, TimeSpan.FromSeconds(5)))
|
||||
.ShouldBeTrue();
|
||||
|
||||
// 3. Read it by RawPath, which is the reference the v3 server uses.
|
||||
var read = await runDrv.ReadAsync(["focas-cnc/cnc/FOCAS/cnc/Identity/SeriesNumber"], CancellationToken.None);
|
||||
|
||||
read[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
read[0].Value.ShouldBe("30i"); // FakeFocasClient.SysInfo.Series
|
||||
await runDrv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>FixedTree.Enabled</c> defaults to false and the universal browser force-enables it only
|
||||
/// for the duration of a browse. Without this, an operator would browse the tree, commit
|
||||
/// leaves, deploy, and land on a driver whose poll loop never starts — every tag Bad, with a
|
||||
/// perfectly correct address. Authoring a FIXED: tag is the request for the tree.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_authored_fixed_tag_arms_the_poll_loop_with_the_config_flag_off()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
new FakeFocasClientFactory(),
|
||||
fixedTreeEnabled: false,
|
||||
new FocasTagDefinition("raw/Program", Host, "FIXED:Program/Name", FocasDataType.String, Writable: false));
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
(await WaitForAsync(
|
||||
() => drv.GetDeviceState(Host)?.FixedTreeCache is not null, TimeSpan.FromSeconds(5)))
|
||||
.ShouldBeTrue("an authored FIXED: tag must start the fixed-tree loop on its own");
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>A driver with no fixed-tree tags and the flag off must still not start the loop.</summary>
|
||||
[Fact]
|
||||
public async Task An_ordinary_tag_does_not_arm_the_poll_loop()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
new FakeFocasClientFactory(),
|
||||
fixedTreeEnabled: false,
|
||||
new FocasTagDefinition("raw/Run", Host, "R100", FocasDataType.Byte));
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(300);
|
||||
|
||||
drv.GetDeviceState(Host)!.FixedTreeCache.ShouldBeNull();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A path this machine does not expose is a genuine <c>BadNodeIdUnknown</c> — the address
|
||||
/// parsed, it simply names nothing here (a capability the series lacks, a renamed axis).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_fixed_path_the_machine_does_not_expose_reads_BadNodeIdUnknown()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
new FakeFocasClientFactory(),
|
||||
fixedTreeEnabled: true,
|
||||
new FocasTagDefinition("raw/Ghost", Host, "FIXED:Axes/Q/AbsolutePosition", FocasDataType.Float64, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await WaitForAsync(
|
||||
() => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null, TimeSpan.FromSeconds(5)))
|
||||
.ShouldBeTrue();
|
||||
|
||||
var read = await drv.ReadAsync(["raw/Ghost"], CancellationToken.None);
|
||||
|
||||
read[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nothing in the fixed tree is a writable location on the CNC — it is a read-out of machine
|
||||
/// state. Refused before a connection is opened.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_fixed_tree_tag_refuses_writes()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
new FakeFocasClientFactory(),
|
||||
fixedTreeEnabled: true,
|
||||
// Writable:true on purpose — the refusal must come from the address kind, not from the
|
||||
// tag's own flag, or an operator could author their way past it.
|
||||
new FocasTagDefinition("raw/Series", Host, "FIXED:Identity/SeriesNumber", FocasDataType.String, Writable: true));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("raw/Series", "nope")], CancellationToken.None);
|
||||
|
||||
results[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A dead CNC must not keep serving its last fixed-tree values as Good. The poll loop
|
||||
/// swallows a failed tick by design (LogDebug + retry), so nothing else marks the caches
|
||||
/// stale — and unlike an area read, a fixed-tree read issues no traffic that could fail.
|
||||
/// This was harmless while nothing could bind to the tree; it is not harmless now.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Fixed_tree_values_go_bad_once_the_device_stops_answering()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
new FakeFocasClientFactory(),
|
||||
fixedTreeEnabled: true,
|
||||
new FocasTagDefinition("raw/Series", Host, "FIXED:Identity/SeriesNumber", FocasDataType.String, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await WaitForAsync(
|
||||
() => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null, TimeSpan.FromSeconds(5)))
|
||||
.ShouldBeTrue();
|
||||
|
||||
(await drv.ReadAsync(["raw/Series"], CancellationToken.None))[0]
|
||||
.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
// Age the last answered poll past the staleness budget. Reaching into DeviceState rather
|
||||
// than sleeping keeps this deterministic and instant; the budget itself is asserted below.
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
state.FixedTreeCts?.Cancel();
|
||||
state.LastFixedPollUtc = DateTime.UtcNow
|
||||
- FocasDriver.FixedTreeStaleAfter(TimeSpan.FromMilliseconds(250))
|
||||
- TimeSpan.FromSeconds(1);
|
||||
|
||||
var stale = await drv.ReadAsync(["raw/Series"], CancellationToken.None);
|
||||
|
||||
stale[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The staleness budget tracks the poll cadence but never drops below 30 s, so an ordinarily
|
||||
/// slow CNC on the sub-second default cadence does not flap between Good and Bad.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(250, 30_000)] // default cadence — the 30 s floor wins
|
||||
[InlineData(1_000, 30_000)] // 8 s of cadence still under the floor
|
||||
[InlineData(10_000, 80_000)] // slow cadence — 8x the interval wins
|
||||
public void Stale_budget_is_eight_cadences_floored_at_thirty_seconds(int pollMs, int expectedMs)
|
||||
=> FocasDriver.FixedTreeStaleAfter(TimeSpan.FromMilliseconds(pollMs))
|
||||
.ShouldBe(TimeSpan.FromMilliseconds(expectedMs));
|
||||
|
||||
/// <summary>Records what <c>DiscoverAsync</c> emits, flattening the folder tree.</summary>
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string name, DriverDataType type, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,66 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #540. The previous edition of this file asserted
|
||||
/// <c>Build("axis", 5) == "axis:5"</c> for four "parameter groups" that are not FOCAS
|
||||
/// addressing spaces — it pinned the exact string the driver could not parse, which is why
|
||||
/// a fully-green suite coexisted with a picker whose default value was unusable. Every case
|
||||
/// here therefore asserts the address ROUND-TRIPS through the driver's parser, not merely
|
||||
/// that it has the expected shape.
|
||||
/// </summary>
|
||||
public sealed class FocasAddressBuilderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("axis", 5, "axis:5")]
|
||||
[InlineData("spindle", 0, "spindle:0")]
|
||||
[InlineData("program", 100, "program:100")]
|
||||
[InlineData("status", 1, "status:1")]
|
||||
public void Build_Canonical(string group, int parameterId, string expected)
|
||||
=> FocasAddressBuilder.Build(group, parameterId).ShouldBe(expected);
|
||||
[InlineData(FocasManualAddressKind.Pmc, "R", 100, null, "R100")]
|
||||
[InlineData(FocasManualAddressKind.Pmc, "X", 0, 0, "X0.0")]
|
||||
[InlineData(FocasManualAddressKind.Pmc, "F", 20, 3, "F20.3")]
|
||||
[InlineData(FocasManualAddressKind.Pmc, "r", 100, null, "R100")] // letter is normalised
|
||||
[InlineData(FocasManualAddressKind.Parameter, null, 1020, null, "PARAM:1020")]
|
||||
[InlineData(FocasManualAddressKind.Parameter, null, 1815, 0, "PARAM:1815/0")]
|
||||
[InlineData(FocasManualAddressKind.Macro, null, 500, null, "MACRO:500")]
|
||||
[InlineData(FocasManualAddressKind.Macro, null, 500, 3, "MACRO:500")] // macros have no bit form
|
||||
public void Build_produces_an_address_the_driver_can_parse(
|
||||
FocasManualAddressKind kind, string? letter, int number, int? bit, string expected)
|
||||
{
|
||||
var built = FocasAddressBuilder.Build(kind, letter, number, bit);
|
||||
|
||||
built.ShouldBe(expected);
|
||||
FocasAddress.TryParse(built).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasManualAddressKind.Pmc, "Z", 10, null)] // not a PMC area letter
|
||||
[InlineData(FocasManualAddressKind.Pmc, "", 10, null)] // no area chosen
|
||||
[InlineData(FocasManualAddressKind.Pmc, "R", 10, 8)] // PMC bits are 0-7
|
||||
[InlineData(FocasManualAddressKind.Pmc, "R", -1, null)] // negative byte
|
||||
[InlineData(FocasManualAddressKind.Parameter, null, 1815, 32)] // parameter bits are 0-31
|
||||
[InlineData(FocasManualAddressKind.Parameter, null, -5, null)]
|
||||
public void Build_returns_empty_rather_than_an_unusable_address(
|
||||
FocasManualAddressKind kind, string? letter, int number, int? bit)
|
||||
=> FocasAddressBuilder.Build(kind, letter, number, bit).ShouldBeEmpty();
|
||||
|
||||
/// <summary>
|
||||
/// The contract that matters, stated over the whole offered surface rather than a sample:
|
||||
/// nothing the picker can compose is unreadable. Every PMC area the picker lists, at a
|
||||
/// whole-word address and at each legal bit.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_offered_pmc_area_composes_a_parseable_address()
|
||||
{
|
||||
foreach (var letter in FocasAddressBuilder.PmcLetters)
|
||||
{
|
||||
foreach (int? bit in new int?[] { null, 0, 7 })
|
||||
{
|
||||
var built = FocasAddressBuilder.Build(FocasManualAddressKind.Pmc, letter, 12, bit);
|
||||
|
||||
built.ShouldNotBeEmpty($"PMC area '{letter}' bit '{bit}' composed nothing");
|
||||
FocasAddress.TryParse(built).ShouldNotBeNull($"'{built}' does not parse");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,10 +74,36 @@ public sealed class FocasTagConfigModelTests
|
||||
new FocasTagConfigModel { Address = "" }.Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_null_when_address_filled()
|
||||
[Theory]
|
||||
[InlineData("R100")]
|
||||
[InlineData("X0.0")]
|
||||
[InlineData("PARAM:1815/0")]
|
||||
[InlineData("MACRO:500")]
|
||||
[InlineData("FIXED:Identity/SeriesNumber")]
|
||||
public void Validate_returns_null_for_every_address_form_the_driver_accepts(string address)
|
||||
=> new FocasTagConfigModel { Address = address }.Validate().ShouldBeNull();
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #540. <c>Validate</c> used to accept any non-blank string, so a browse full
|
||||
/// reference sailed through authoring, through the deploy gate, and was then skipped at
|
||||
/// driver init — where the only trace was a Debug-level log line nobody reads. The first
|
||||
/// case here is verbatim what the AdminUI showed for one of the 20 dead tags on the Makino.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("10.201.31.5:8193/Identity/SeriesNumber")]
|
||||
[InlineData("focas://10.201.31.5:8193/Axes/X/AbsolutePosition")]
|
||||
[InlineData("axis:5")]
|
||||
[InlineData("Z99")] // Z is not a PMC area letter
|
||||
[InlineData("PARAM:notanumber")]
|
||||
[InlineData("FIXED:")]
|
||||
public void Validate_rejects_an_address_the_driver_would_skip(string address)
|
||||
{
|
||||
new FocasTagConfigModel { Address = "R100" }.Validate().ShouldBeNull();
|
||||
var error = new FocasTagConfigModel { Address = address }.Validate();
|
||||
|
||||
error.ShouldNotBeNull();
|
||||
// The operator has to be able to act on it, so the message names the legal forms.
|
||||
error.ShouldContain("PARAM:");
|
||||
error.ShouldContain("FIXED:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -107,6 +108,33 @@ public sealed class RawBrowseCommitMapperTests
|
||||
private static Dictionary<string, string> NativeTypeFields(string nativeType) =>
|
||||
new() { ["dataType"] = nativeType };
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #540 — the commit path, judged by whether what it produces is authorable rather
|
||||
/// than by which key it lands under.
|
||||
/// <para>
|
||||
/// The mapper is not where that defect lived: it faithfully wrote the reference the FOCAS
|
||||
/// browser handed it, which was <c>{deviceHost}/{path}</c> and parses as nothing. The fix
|
||||
/// was in what the driver emits, so the check that keeps it fixed is that a leaf reference
|
||||
/// survives the commit AND passes the same validation the modal applies — a mapper test
|
||||
/// asserting only <c>o["address"] == reference</c> passed happily throughout.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="reference">A leaf reference of the kind the FOCAS browser emits.</param>
|
||||
[Theory]
|
||||
[InlineData("FIXED:Identity/SeriesNumber")]
|
||||
[InlineData("FIXED:Axes/X/AbsolutePosition")]
|
||||
[InlineData("FIXED:Spindle/S1/Load")]
|
||||
[InlineData("FIXED:Timers/PowerOnSeconds")]
|
||||
[InlineData("MACRO:3901")]
|
||||
public void A_committed_FOCAS_leaf_is_an_address_the_authoring_gate_accepts(string reference)
|
||||
{
|
||||
var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.FOCAS, reference);
|
||||
|
||||
var model = FocasTagConfigModel.FromJson(json);
|
||||
model.Address.ShouldBe(reference);
|
||||
model.Validate().ShouldBeNull($"'{reference}' committed to a tag the driver would skip");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The committed <c>dataType</c> is the type the CONTROLLER reported, not the model default.
|
||||
/// AB CIP / TwinCAT / FOCAS decode the wire bytes by this field, so a browsed REAL left at the
|
||||
|
||||
Reference in New Issue
Block a user