fix(focas): browse-committed fixed-tree tags are readable (Gitea #540)

Authoring FOCAS tags through /raw -> Browse device... produced tags the driver
could never read. Every committed tag read BadNodeIdUnknown forever, and the
failure was silent end to end: the AdminUI accepted it, the deploy gate sealed
green, and the driver reported Healthy with 0 errors while ignoring the tags.
Net effect: the whole fixed-tree surface could not be published through any v3
authoring path. Found cutting wonder-app-vd03 over to v3 against a real Makino
Pro 5 / Fanuc 30i, where 0 of 20 committed tags read while two hand-authored
MACRO: tags on the same device read Good.

A captured browse leaf's id IS its DriverAttributeInfo.FullName, and the fixed
tree emitted "{deviceHost}/{path}" there -- an internal cache key, not an
address. Both authoring paths wrote it verbatim into TagConfig.address, and
FocasAddress.TryParse accepts no such form, so the driver skipped the tags at
init.

Fixed at the emitter rather than by stripping the host at commit, which fixes
the browse-commit flow and the manual picker's tree-select together and leaves
one canonical address form:

- FocasAddress gains a fourth kind, FIXED:<path>, and moves to Contracts so the
  AdminUI and ControlPlane can parse without referencing the runtime driver
  (#526). A FIXED: read is served from the fixed-tree snapshot the poll loop
  already maintains -- no area read, no connection. Writes refuse: nothing in
  that tree is a writable location on the CNC.
- The address carries no device. A tag's device comes from its Device row, and
  a second routing channel for one fact is what created this defect.
- An authored FIXED: tag arms the fixed-tree poll loop by itself. Enabled
  defaults false and the browser force-enables it only for a browse, so the
  operator would otherwise browse, commit, deploy, and land on a driver whose
  loop never starts -- every tag Bad with a correct address.
- Fixed-tree values are now dated from the last answered poll and go
  BadCommunicationError once it is stale (8 cadences, floored at 30s). The poll
  loop swallows a failed tick by design, so a dead CNC would otherwise serve its
  last snapshot as Good indefinitely -- harmless while nothing could bind to the
  tree, not harmless now.

Guardrails, so this class cannot recur silently:

- FocasTagConfigModel.Validate() runs the real parser instead of a non-empty
  check, and FocasTagDefinitionFactory.Inspect() warns at the deploy gate, which
  covers CSV/API config that never touches the modal.
- The manual picker offered axis/spindle/program/status "parameter groups" --
  none of which is a FOCAS addressing space -- and its own default value,
  axis:5, was unusable. It now composes PMC / PARAM: / MACRO: through
  FocasAddress, so what it emits is parseable by construction.

Tests assert addresses ROUND-TRIP through the parser rather than matching an
expected string: the previous FocasAddressBuilderTests pinned "axis:5" exactly,
which is how a green suite coexisted with a picker nobody could use. Verified
red-first against the old emitter. FOCAS 308, AdminUI 1095, ControlPlane 169,
Core 581 -- all pass, 0 warnings.

The remaining half of #540 -- a driver reporting Healthy/0 errors while
skipping tags at init -- is fleet-wide (eight drivers do this) and is filed
separately rather than patched per-driver.
This commit is contained in:
Joseph Doherty
2026-08-05 17:32:18 -04:00
parent 4e4619514c
commit b61fe38823
14 changed files with 1147 additions and 251 deletions
@@ -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);
}
@@ -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.";
}