8b10d32728
A CNC can report duplicate sibling names — the wonder Makino returns two
axes both called AA — which made the Axes/ subtree untrustworthy: the
browser offered two identical folders the commit gate had to refuse, and
the poll loop's name-keyed snapshot cache let the second axis silently
overwrite the first. Spindles and servo loads had the same collision in
their own keying, plus a resolve-to-first-match on the spindle read path.
Scheme: every member of a collision set gets a 1-based occurrence-ordinal
suffix in slot order (AA_1, AA_2); unique names stay bare. Chosen over the
alternatives deliberately:
- Names, not slot numbers, stay the primary identity — the segment map is
re-derived from the live device each session, so a name-keyed path
follows the physical axis across config changes, where a slot-qualified
path would silently rebind on any axis-list shift and rename every axis
on every machine for one pathological CNC.
- The WHOLE collision set is suffixed: bare AA ceases to exist when
ambiguous, so a tag authored against it reads BadNodeIdUnknown rather
than one arbitrary axis's data — the #534/#540 silent-wrongness class.
- Occurrence ordinal, not absolute slot: inserting/removing an unrelated
axis never renames an existing duplicate pair. If the collision-set
membership itself changes, segments change on the next browse and stale
authored segments read Bad, never another axis's data.
One pure helper (FixedTreeNames.Disambiguate, collision-proof against
genuine names by suffix escalation) feeds FocasFixedTreeCache.AxisSegments
/ SpindleSegments, and the emitter, axis snapshot keys, servo-load keys
(cnc_rdsvmeter answers in slot order, so the k-th AA load belongs to the
k-th AA axis) and the spindle read resolution all consume those — they can
no longer disagree. The nameless-spindle S{n} fallback applies before
disambiguation so a fallback name colliding with a real one is handled
like any other duplicate.
Tests are red-first on the pre-fix driver (all five driver-level tests
fail on master's code): browse emits AA_1/AA_2 and no bare AA, each
duplicate reads its own position and servo load, duplicate spindles
resolve per slot, bare AA reads Bad. The wire half runs live against
focas-mock (duplicate names survive the managed wire parse and publish as
suffixed segments); the mock's dynamic store is name-keyed so the
distinct-values half stays with the offline tests. FOCAS 327/327,
integration 11/11, FOCAS.Cli 52, AdminUI 1113, ControlPlane 231.
Closes #548.
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
96 lines
4.7 KiB
C#
96 lines
4.7 KiB
C#
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|
|
|
/// <summary>
|
|
/// Turns a device-reported sibling-name sequence (axes, spindles) into unique fixed-tree
|
|
/// path segments (Gitea #548).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A real CNC can report duplicate sibling names — one observed Makino returns two axis
|
|
/// folders both called <c>AA</c>. Before this existed, the emitter offered two identical
|
|
/// browse folders, commit-time collision detection refused to author both (same RawPath),
|
|
/// and the poll loop's name-keyed snapshot cache let the second axis silently overwrite the
|
|
/// first — so the <c>Axes/</c> subtree was untrustworthy on such a machine.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Scheme: every member of a collision set gets a 1-based occurrence-ordinal suffix in
|
|
/// slot order (<c>AA_1</c>, <c>AA_2</c>); unique names stay bare.</b> Three deliberate
|
|
/// choices inside that:
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Names stay the primary identity, not slot numbers.</b> The segment map is re-derived
|
|
/// from the live device at every session bootstrap, so a name-keyed path follows the
|
|
/// physical axis across configuration changes; a slot-qualified path (<c>3_AA</c>) would
|
|
/// silently rebind to a different physical axis whenever the axis list shifts, and would
|
|
/// rename every axis path on every machine for the sake of the rare duplicate.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The whole collision set is suffixed, not just the second occurrence.</b> When two
|
|
/// axes are both called <c>AA</c>, the bare segment <c>AA</c> ceases to exist — a bare name
|
|
/// that silently means "one of the two" is the same silent-wrongness class #534 and #540
|
|
/// were about. A tag authored against bare <c>AA</c> on such a machine reads Bad, never one
|
|
/// arbitrary axis's data.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Occurrence ordinal, not absolute slot number.</b> The ordinal is stable under
|
|
/// insertion/removal of <em>unrelated</em> axes (a new <c>B</c> axis ahead of the two AAs
|
|
/// renames nothing; their slot numbers would both change). If the collision-set membership
|
|
/// itself changes — a third <c>AA</c> appears, or one of two is removed — the segments do
|
|
/// change; that surfaces on the next browse + commit, and until then the stale authored
|
|
/// segment reads Bad rather than another axis's data (the served tree only changes on a
|
|
/// config redeploy).
|
|
/// </para>
|
|
/// <para>
|
|
/// Suffix collisions with genuine names are structurally impossible for axes (FOCAS axis
|
|
/// displays are at most two characters — name + suffix — so they cannot contain <c>_</c>),
|
|
/// but the generator still checks: a generated segment that would collide with anything
|
|
/// else in the set escalates its ordinal until unique, so the output is always
|
|
/// collision-free whatever the input.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static class FixedTreeNames
|
|
{
|
|
/// <summary>
|
|
/// Maps each name in <paramref name="names"/> (in slot order) to a unique sibling path
|
|
/// segment. Unique names map to themselves; duplicated names map to
|
|
/// <c>{name}_{occurrence}</c>. Comparison is case-insensitive, matching the fixed-tree
|
|
/// snapshot cache and read-path lookups.
|
|
/// </summary>
|
|
/// <param name="names">Device-reported sibling names, in device (slot) order.</param>
|
|
/// <returns>One segment per input name, unique within the returned list.</returns>
|
|
public static IReadOnlyList<string> Disambiguate(IEnumerable<string> names)
|
|
{
|
|
var list = names as IReadOnlyList<string> ?? [.. names];
|
|
|
|
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var name in list)
|
|
counts[name] = counts.TryGetValue(name, out var c) ? c + 1 : 1;
|
|
|
|
// Bare (unique) names are claimed first so a generated "{name}_{k}" can never shadow one.
|
|
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var name in list)
|
|
if (counts[name] == 1)
|
|
used.Add(name);
|
|
|
|
var nextOrdinal = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
|
var result = new string[list.Count];
|
|
for (var i = 0; i < list.Count; i++)
|
|
{
|
|
var name = list[i];
|
|
if (counts[name] == 1)
|
|
{
|
|
result[i] = name;
|
|
continue;
|
|
}
|
|
|
|
var k = nextOrdinal.TryGetValue(name, out var n) ? n : 1;
|
|
string segment;
|
|
do { segment = $"{name}_{k}"; k++; }
|
|
while (!used.Add(segment));
|
|
nextOrdinal[name] = k;
|
|
result[i] = segment;
|
|
}
|
|
return result;
|
|
}
|
|
}
|