diff --git a/docs/drivers/FOCAS.md b/docs/drivers/FOCAS.md
index 83504579..326c11ae 100644
--- a/docs/drivers/FOCAS.md
+++ b/docs/drivers/FOCAS.md
@@ -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
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasAddress.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasAddress.cs
new file mode 100644
index 00000000..862f7e10
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasAddress.cs
@@ -0,0 +1,178 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+///
+/// Parsed FOCAS address covering the four addressing spaces a driver touches:
+/// (letter + byte + optional bit — X0.0, R100,
+/// F20.3), (CNC parameter number —
+/// PARAM:1020, PARAM:1815/0 for bit 0),
+/// (macro variable number — MACRO:100, MACRO:500), and
+/// (a logical path into the driver's fixed tree —
+/// FIXED:Identity/SeriesNumber, FIXED:Axes/X/AbsolutePosition).
+///
+///
+/// PMC letters: X/Y (IO), F/G (signals between PMC + CNC), R (internal
+/// relay), D (data table), C (counter), K (keep relay), A
+/// (message display), E (extended relay), T (timer). Byte numbering is 0-based;
+/// bit index when present is 0–7 and uses .N for PMC or /N for parameters.
+///
+/// FIXED: is not an area read. The first three forms name a location the driver
+/// asks the CNC for on demand; a FIXED: path names a field of the per-device fixed-tree
+/// snapshot the driver's own poll loop already maintains (cnc_sysinfo, cnc_rddynamic2,
+/// 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).
+///
+///
+/// The path carries no device. 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 {host}/{path} full reference was
+/// written into the address field verbatim and then failed to parse, so every browse-committed
+/// fixed-tree tag read BadNodeIdUnknown forever.
+///
+///
+public sealed record FocasAddress(
+ FocasAreaKind Kind,
+ string? PmcLetter,
+ int Number,
+ int? BitIndex,
+ string? FixedPath = null)
+{
+ /// The prefix that introduces a address.
+ public const string FixedPrefix = "FIXED:";
+
+ /// Gets the canonical string representation of this address.
+ 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}",
+ };
+
+ ///
+ /// Builds the canonical authored address for a fixed-tree path. The one place the
+ /// FIXED: spelling is constructed, so the browse emitter and the parser cannot drift.
+ ///
+ /// The fixed-tree path, e.g. Identity/SeriesNumber.
+ /// The canonical authored address string.
+ public static string ForFixedPath(string path) => FixedPrefix + path;
+
+ /// Attempts to parse a FOCAS address from the given string.
+ /// The address string to parse.
+ /// A FocasAddress if parsing succeeds; otherwise null.
+ 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);
+ }
+
+ ///
+ /// Validates a fixed-tree path and returns it, or null when it is not a usable path.
+ /// Accepts Segment[/Segment…] where each segment is non-blank and carries no
+ /// : — a colon would collide with the PARAM:/MACRO:/FIXED:
+ /// prefix grammar and make the address ambiguous to read back.
+ ///
+ ///
+ /// 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.
+ ///
+ 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,
+ };
+}
+
+/// Addressing-space kinds the driver understands.
+public enum FocasAreaKind
+{
+ /// PMC area — letter + byte + optional bit (X0.0, R100).
+ Pmc,
+
+ /// CNC parameter number (PARAM:1020, PARAM:1815/0).
+ Parameter,
+
+ /// Macro variable number (MACRO:100).
+ Macro,
+
+ ///
+ /// A logical path into the driver's per-device fixed tree (FIXED:Identity/SeriesNumber).
+ /// 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.
+ ///
+ Fixed,
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs
index 7bef3447..3ece4da2 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs
@@ -117,6 +117,21 @@ public static class FocasTagDefinitionFactory
}
var w = TagConfigJson.DescribeInvalidEnum(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.");
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs
deleted file mode 100644
index df4434f0..00000000
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs
+++ /dev/null
@@ -1,99 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
-
-///
-/// Parsed FOCAS address covering the three addressing spaces a driver touches:
-/// (letter + byte + optional bit — X0.0, R100,
-/// F20.3), (CNC parameter number —
-/// PARAM:1020, PARAM:1815/0 for bit 0), and
-/// (macro variable number — MACRO:100, MACRO:500).
-///
-///
-/// PMC letters: X/Y (IO), F/G (signals between PMC + CNC), R (internal
-/// relay), D (data table), C (counter), K (keep relay), A
-/// (message display), E (extended relay), T (timer). Byte numbering is 0-based;
-/// bit index when present is 0–7 and uses .N for PMC or /N for parameters.
-///
-public sealed record FocasAddress(
- FocasAreaKind Kind,
- string? PmcLetter,
- int Number,
- int? BitIndex)
-{
- /// Gets the canonical string representation of this address.
- 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}",
- };
-
- /// Attempts to parse a FOCAS address from the given string.
- /// The address string to parse.
- /// A FocasAddress if parsing succeeds; otherwise null.
- 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,
- };
-}
-
-/// Addressing-space kinds the driver understands.
-public enum FocasAreaKind
-{
- Pmc,
- Parameter,
- Macro,
-}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs
index 4d11dff8..b8037e54 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
///
-/// Documented-API capability matrix — per CNC series, what ranges each
+/// Documented-API capability matrix — per CNC series, what ranges each addressed
/// supports. Authoritative source for the driver's
/// pre-flight validation in .
///
@@ -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,
};
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
index aea048ff..898d5a6d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -49,6 +49,21 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// produced equipment tags are encountered for the first time.
private readonly ConcurrentDictionary _parsedAddressesByTagName =
new(StringComparer.OrdinalIgnoreCase);
+ ///
+ /// Whether this driver instance runs its per-device fixed-tree poll loop, and therefore
+ /// serves fixed-tree values. Computed at from the config flag
+ /// OR the presence of at least one authored tag.
+ ///
+ ///
+ /// The OR is load-bearing.FocasFixedTreeOptions.Enabled defaults to
+ /// 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 FIXED: tag IS the request for the tree; the config flag stays
+ /// as the way to publish it without authoring anything.
+ ///
+ 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 }
+ ///
+ /// Emit one Identity leaf.
+ ///
+ ///
+ ///
+ /// The emitted FullName is the authorable address, not the runtime cache key.
+ /// A captured browse leaf's id IS its FullName, and that string is what both the
+ /// /raw browse-commit mapper and the manual picker's tree-select write into the tag's
+ /// TagConfig.address. 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 BadNodeIdUnknown forever, while the deploy sealed green and driver health
+ /// stayed Healthy (Gitea #540).
+ ///
+ ///
+ /// 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. keeps the host for the internal
+ /// per-device caches, which do need to distinguish devices.
+ ///
+ ///
+ /// The address-space folder to emit into.
+ /// The identity field name.
+ /// The field's data type.
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)));
}
+ /// Emit one Axes leaf. See on the address shape.
+ /// The address-space folder to emit into.
+ /// The axis display name.
+ /// The per-axis field name.
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,
///
/// Emit a variable under a named fixed-tree folder (Program, OperationMode,
- /// …). Full-reference shape is {deviceHost}/{folderPath}/{field}.
+ /// …). Address shape is FIXED:{folderPath}/{field} — see
+ /// .
///
+ /// The address-space folder to emit into.
+ /// The fixed-tree folder path.
+ /// The field name.
+ /// The field's data type.
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,
}
///
- /// Canonical full-reference shape for a fixed-tree node. Keeps the device
+ /// Canonical runtime cache key 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 looks up.
+ /// what looks up.
///
+ ///
+ /// This is an internal key, not an authorable address — see
+ /// for why the two are deliberately different strings
+ /// (Gitea #540).
+ ///
/// The host address of the device.
/// The path within the fixed tree.
- /// The canonical full reference for the node.
+ /// The canonical cache key for the node.
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);
- }
+ ///
+ /// 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.
+ ///
+ /// The configured fixed-tree poll interval.
+ /// The staleness budget.
+ 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)
+ ///
+ /// Resolves one fixed-tree (Identity/SeriesNumber,
+ /// Axes/X/AbsolutePosition, …) against a single device's caches. Returns null when
+ /// this device's fixed tree has no such path — the caller decides what that means.
+ ///
+ /// The device whose caches to read.
+ /// The fixed-tree path, with no device prefix.
+ /// The read timestamp.
+ /// The snapshot, or null when the path names nothing on this device.
+ ///
+ /// Values are dated from , not from
+ /// , and go BadCommunicationError once that stamp is older than
+ /// . 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.
+ ///
+ 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; }
/// Gets or sets the fixed-tree cache for this device.
public FocasFixedTreeCache? FixedTreeCache { get; set; }
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// This is what stops a dead CNC publishing its last values as Good forever. 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 .
+ ///
+ public DateTime? LastFixedPollUtc { get; set; }
///
/// Gets the last fixed-tree snapshots by field name. Double-typed so axis
/// positions can carry the 10^PositionDecimalPlaces engineering-unit
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor
index 1f1cfe2d..5c0a17be 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor
@@ -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
@code {
@@ -79,8 +116,10 @@
/// Live accessor for the selected driver's DriverConfig JSON (browse connect config).
[Parameter] public Func 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);
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FocasAddressBuilder.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FocasAddressBuilder.cs
index 94e2708d..3c52af1d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FocasAddressBuilder.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FocasAddressBuilder.cs
@@ -1,16 +1,76 @@
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
///
-/// 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
+/// : is deliberately absent because a
+/// fixed-tree path is discovered from the machine, not composed from parts — the picker's browse
+/// tree supplies those.
+///
+public enum FocasManualAddressKind
+{
+ /// PMC area — letter + byte + optional bit.
+ Pmc,
+
+ /// CNC parameter number + optional bit.
+ Parameter,
+
+ /// Macro variable number.
+ Macro,
+}
+
+///
+/// Pure static helper that composes a canonical FOCAS address from the manual picker's parts.
/// Extracted so unit tests can call it without bUnit.
///
+///
+///
+/// It builds through rather than formatting a string, so what
+/// it emits is parseable by construction and the two cannot drift.
+///
+///
+/// It previously emitted $"{group}:{parameterId}" over a group list of
+/// axis/spindle/program/status — none of which is a FOCAS addressing space — so the
+/// picker's own default value, axis:5, 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).
+///
+///
public static class FocasAddressBuilder
{
- /// Builds the canonical FOCAS address string from a parameter group and parameter ID.
- /// The FOCAS parameter group (e.g. "axis").
- /// The parameter ID within the group.
- /// The canonical address string in group:parameterId form (e.g. axis:5).
- public static string Build(string group, int parameterId)
- => $"{group}:{parameterId}";
+ /// The PMC area letters the driver accepts, in the order the picker offers them.
+ public static IReadOnlyList PmcLetters { get; } =
+ ["X", "Y", "F", "G", "R", "D", "C", "K", "A", "E", "T"];
+
+ ///
+ /// 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).
+ ///
+ /// Which addressing space the address is in.
+ /// The PMC area letter; ignored unless is
+ /// .
+ /// The PMC byte, parameter number or macro number.
+ /// 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.
+ /// The canonical address, or an empty string when the parts are not addressable.
+ 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 : "";
+ }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/FocasTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/FocasTagConfigModel.cs
index ac159401..b7a22faf 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/FocasTagConfigModel.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/FocasTagConfigModel.cs
@@ -47,6 +47,32 @@ public sealed class FocasTagConfigModel
/// Validation hook; returns an error message or null when the model is valid.
/// An error message describing the validation failure, or null when the model is valid.
+ ///
+ /// This runs the driver's real parser, not a non-empty check (Gitea #540). It used to
+ /// accept any non-blank string, which is how 20 tags carrying a browse full reference
+ /// (10.201.31.5:8193/Identity/SeriesNumber) were authored, deployed and sealed green —
+ /// the driver then skipped every one at init and they read BadNodeIdUnknown 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.
+ /// lives in the driver's Contracts assembly precisely
+ /// so this project can call it without referencing the runtime driver (Gitea #526).
+ ///
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;
+ }
+
+ ///
+ /// 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.
+ ///
+ /// The rejected address.
+ /// The message.
+ 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.";
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAddressFixedFormTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAddressFixedFormTests.cs
new file mode 100644
index 00000000..2bbb14a0
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAddressFixedFormTests.cs
@@ -0,0 +1,90 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// The FIXED: address form added by Gitea #540, and the guarantee that adding it did not
+/// disturb the three forms that were already there.
+///
+[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();
+
+ ///
+ /// 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.
+ ///
+ [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();
+
+ ///
+ /// F is a valid PMC area letter, so the FIXED: prefix has to be tested before
+ /// the PMC branch. Both forms must survive that ordering.
+ ///
+ [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);
+ }
+
+ /// The one place the FIXED: spelling is built, so emitter and parser cannot drift.
+ [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");
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeAddressingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeAddressingTests.cs
new file mode 100644
index 00000000..f8f4a99a
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeAddressingTests.cs
@@ -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;
+
+///
+/// Gitea #540 — authoring a fixed-tree tag through /raw → Browse device… produced a tag
+/// the driver could never read. The browse leaf's full reference was {deviceHost}/{path},
+/// the commit mapper wrote it verbatim into TagConfig.address, and
+/// accepts no such form — so the driver skipped the tag at
+/// init and every read returned BadNodeIdUnknown, indefinitely, while the deploy sealed
+/// green and driver health stayed Healthy with zero errors.
+///
+/// Found on a real Makino Pro 5 / Fanuc 30i: 0 of 20 browse-committed tags read, while two
+/// hand-authored MACRO: tags on the same device read Good.
+///
+///
+[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 WaitForAsync(Func predicate, TimeSpan timeout)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ if (predicate()) return true;
+ await Task.Delay(25);
+ }
+ return predicate();
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// FixedTree.Enabled 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.
+ ///
+ [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);
+ }
+
+ /// A driver with no fixed-tree tags and the flag off must still not start the loop.
+ [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);
+ }
+
+ ///
+ /// A path this machine does not expose is a genuine BadNodeIdUnknown — the address
+ /// parsed, it simply names nothing here (a capability the series lacks, a renamed axis).
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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));
+
+ /// Records what DiscoverAsync emits, flattening the folder tree.
+ 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) { }
+ }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Pickers/FocasAddressBuilderTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Pickers/FocasAddressBuilderTests.cs
index d95d057a..1edd0323 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Pickers/FocasAddressBuilderTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Pickers/FocasAddressBuilderTests.cs
@@ -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;
+///
+/// Gitea #540. The previous edition of this file asserted
+/// Build("axis", 5) == "axis:5" 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.
+///
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();
+
+ ///
+ /// 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.
+ ///
+ [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");
+ }
+ }
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/FocasTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/FocasTagConfigModelTests.cs
index f7dbca89..c5d2ae6c 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/FocasTagConfigModelTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/FocasTagConfigModelTests.cs
@@ -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();
+
+ ///
+ /// Gitea #540. Validate 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.
+ ///
+ [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]
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawBrowseCommitMapperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawBrowseCommitMapperTests.cs
index d4cb90b9..79aebcb0 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawBrowseCommitMapperTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawBrowseCommitMapperTests.cs
@@ -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 NativeTypeFields(string nativeType) =>
new() { ["dataType"] = nativeType };
+ ///
+ /// Gitea #540 — the commit path, judged by whether what it produces is authorable rather
+ /// than by which key it lands under.
+ ///
+ /// The mapper is not where that defect lived: it faithfully wrote the reference the FOCAS
+ /// browser handed it, which was {deviceHost}/{path} 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 o["address"] == reference passed happily throughout.
+ ///
+ ///
+ /// A leaf reference of the kind the FOCAS browser emits.
+ [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");
+ }
+
///
/// The committed dataType 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