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
+53 -5
View File
@@ -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