feat(otopcua): multi-device-per-driver FixedTree partition (follow-up E)
This commit is contained in:
@@ -577,11 +577,23 @@ public static class AddressSpaceComposer
|
|||||||
var raw = hostEl.GetString();
|
var raw = hostEl.GetString();
|
||||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||||
// Deterministic normalization (trim + lower-case) so both seams produce the identical string.
|
// Deterministic normalization (trim + lower-case) so both seams produce the identical string.
|
||||||
return raw.Trim().ToLowerInvariant();
|
return NormalizeDeviceHost(raw);
|
||||||
}
|
}
|
||||||
catch (JsonException) { return null; }
|
catch (JsonException) { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The SINGLE SOURCE OF TRUTH for device-host normalization: trims surrounding whitespace and
|
||||||
|
/// lower-cases (invariant). <see cref="TryExtractDeviceHost"/> applies this to a <c>Device</c>'s
|
||||||
|
/// parsed <c>HostAddress</c>, and the FixedTree-partition path (<c>DriverHostActor</c>) applies the
|
||||||
|
/// SAME function to a driver-discovered device-host folder segment before comparing the two — so an
|
||||||
|
/// <see cref="EquipmentNode.DeviceHost"/> and a captured folder segment for the same device compare
|
||||||
|
/// equal regardless of case/whitespace. Idempotent (a value already normalized is unchanged).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="host">The raw host string (non-null; a non-empty <c>HostAddress</c> or folder segment).</param>
|
||||||
|
/// <returns>The normalized host (trimmed + lower-cased).</returns>
|
||||||
|
public static string NormalizeDeviceHost(string host) => host.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
|
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
|
||||||
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
|
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
|
||||||
/// artifact-decode side (<c>DeploymentArtifact.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
|
/// artifact-decode side (<c>DeploymentArtifact.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
|
||||||
|
|||||||
@@ -174,6 +174,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
private readonly Dictionary<string, Dictionary<string, DiscoveredInjectionPlan>> _discoveredByDriver =
|
private readonly Dictionary<string, Dictionary<string, DiscoveredInjectionPlan>> _discoveredByDriver =
|
||||||
new(StringComparer.Ordinal);
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Per-driver signature of the last-logged device-host PARTITION diagnostic (unmatched / ambiguous
|
||||||
|
/// / degenerate host), folded with the current revision, so the ~15 repeated re-discovery passes within a
|
||||||
|
/// connect don't re-warn an unchanged condition: it is WARNED once when it first appears (or changes), and
|
||||||
|
/// DEBUG-logged on the identical repeat passes. Folding in <see cref="_currentRevision"/> makes a redeploy
|
||||||
|
/// re-warn once. Best-effort LOG-LEVEL dedup ONLY — never affects grafting; the matched-plan re-apply is
|
||||||
|
/// separately short-circuited by <see cref="PlansRoutingEqual"/>. Cleared for a driver whose partition comes
|
||||||
|
/// back clean so a later recurrence re-warns; bounded by driver count (a few). Only touched on the
|
||||||
|
/// multi-candidate path (<see cref="PartitionDiscoveredByDeviceHost"/>).</summary>
|
||||||
|
private readonly Dictionary<string, string> _lastPartitionWarnSignature = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cached local <see cref="RedundancyRole"/> from the latest <see cref="RedundancyStateChanged"/>
|
/// Cached local <see cref="RedundancyRole"/> from the latest <see cref="RedundancyStateChanged"/>
|
||||||
/// snapshot (null = unknown until the first snapshot arrives, or no local node match). The inbound
|
/// snapshot (null = unknown until the first snapshot arrives, or no local node match). The inbound
|
||||||
@@ -611,44 +621,51 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
_localNode, msg.DriverInstanceId);
|
_localNode, msg.DriverInstanceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (equipmentIds.Count > 1)
|
// Authored refs for THIS driver (DRIVER-WIDE — both value + alarm tags) so a discovered node never
|
||||||
{
|
// shadows an authored one — the mapper drops any captured node whose FullReference is already authored.
|
||||||
// NEXT TASK (multi-device partition) REPLACES THIS BRANCH: a driver that fans out to multiple
|
// May be EMPTY for a tag-less equipment, which is fine: Map dedups against an empty set (keeps
|
||||||
// equipments (one per device-host) will partition its discovered FixedTree by DeviceHost and graft
|
// everything). Safe even for the multi-device partition below: a FOCAS FullReference is host-prefixed,
|
||||||
// each partition under its matching equipment, populating multiple inner-map entries. Until then we
|
// so a device-X discovered node can't collide with a device-Y authored ref — the driver-wide set is
|
||||||
// keep the conservative warn+skip — a single-equipment graft is the only shape this task handles.
|
// correct per partition.
|
||||||
_log.Warning("DriverHost {Node}: driver {Driver} maps to {Count} equipments — discovered-node injection skipped (multi-equipment-per-driver is a follow-up)",
|
|
||||||
_localNode, msg.DriverInstanceId, equipmentIds.Count);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var equipmentId = equipmentIds[0];
|
|
||||||
|
|
||||||
// Authored refs for THIS driver (both value + alarm tags) so a discovered node never shadows an
|
|
||||||
// authored one — the mapper drops any captured node whose FullReference is already authored. May be
|
|
||||||
// EMPTY for a tag-less equipment, which is fine: Map dedups against an empty set (keeps everything).
|
|
||||||
var authoredRefs = _lastComposition.EquipmentTags
|
var authoredRefs = _lastComposition.EquipmentTags
|
||||||
.Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal))
|
.Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal))
|
||||||
.Select(t => t.FullName)
|
.Select(t => t.FullName)
|
||||||
.ToHashSet(StringComparer.Ordinal);
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
var plan = DiscoveredNodeMapper.Map(equipmentId, msg.Nodes, authoredRefs);
|
// Build this discovery's per-equipment plan map.
|
||||||
|
// • EXACTLY ONE candidate ⇒ map the WHOLE captured tree under it (the mapper collapses the single
|
||||||
|
// device-host folder ⇒ clean EQ-n/FOCAS/...). Unchanged from before.
|
||||||
|
// • MORE THAN ONE candidate ⇒ PARTITION the captured tree by its (normalized) device-host folder
|
||||||
|
// segment and graft each device's subset under the equipment whose DeviceHost matches (follow-up E
|
||||||
|
// part 2). Unmatched/ambiguous hosts are warn-skipped (safe), not mis-grafted; a degenerate case
|
||||||
|
// (>1 candidate, none has a DeviceHost) warn-skips the whole driver. See PartitionDiscoveredByDeviceHost.
|
||||||
|
Dictionary<string, DiscoveredInjectionPlan> newPlans;
|
||||||
|
if (equipmentIds.Count == 1)
|
||||||
|
{
|
||||||
|
var plan = DiscoveredNodeMapper.Map(equipmentIds[0], msg.Nodes, authoredRefs);
|
||||||
if (plan.Variables.Count == 0) return; // nothing new to inject (all captured nodes were authored)
|
if (plan.Variables.Count == 0) return; // nothing new to inject (all captured nodes were authored)
|
||||||
|
newPlans = new Dictionary<string, DiscoveredInjectionPlan>(StringComparer.Ordinal) { [equipmentIds[0]] = plan };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
newPlans = PartitionDiscoveredByDeviceHost(msg, equipmentIds, authoredRefs);
|
||||||
|
if (newPlans.Count == 0) return; // degenerate / no host matched a graftable partition — already logged
|
||||||
|
}
|
||||||
|
|
||||||
// The driver's per-equipment plan map for this discovery. Single-equipment today ⇒ one entry; the
|
// Unchanged-plan short-circuit (shared by the single- AND multi-device paths): the driver re-discovers
|
||||||
// multi-device task will add an entry per partitioned equipment here.
|
// every ~2s (up to ~15 passes) until the FixedTree set stabilises, re-sending DiscoveredNodesReady each
|
||||||
var newPlans = new Dictionary<string, DiscoveredInjectionPlan>(StringComparer.Ordinal) { [equipmentId] = plan };
|
// pass. Re-applying an IDENTICAL set would re-send SetDesiredSubscriptions, forcing the child to
|
||||||
|
// UnsubscribeAsync (dropping the WHOLE handle — authored tags included) then re-Subscribe — blipping
|
||||||
// Unchanged-plan short-circuit: the driver re-discovers every ~2s (up to ~15 passes) until the
|
// authored-tag values up to ~15× across the discovery window. Skip when the WHOLE per-equipment routing
|
||||||
// FixedTree set stabilises, re-sending DiscoveredNodesReady each pass. Re-applying an IDENTICAL set
|
// is unchanged from the last applied pass; a GROWING set still differs (superset) and re-applies. (This
|
||||||
// would re-send SetDesiredSubscriptions, forcing the child to UnsubscribeAsync (dropping the WHOLE
|
// is also why an unmatched/ambiguous partition warning settles: once the matched partitions stabilise we
|
||||||
// handle — authored tags included) then re-Subscribe — blipping authored-tag values up to ~15× across
|
// short-circuit here, and the partition warns are themselves signature-deduped — see ShouldWarnPartition.)
|
||||||
// the discovery window. Skip when the WHOLE per-equipment routing is unchanged from the last applied
|
|
||||||
// pass; a GROWING set still differs (superset) and re-applies. This is _discoveredByDriver's first reader.
|
|
||||||
if (_discoveredByDriver.TryGetValue(msg.DriverInstanceId, out var cached)
|
if (_discoveredByDriver.TryGetValue(msg.DriverInstanceId, out var cached)
|
||||||
&& PlansRoutingEqual(cached, newPlans))
|
&& PlansRoutingEqual(cached, newPlans))
|
||||||
{
|
{
|
||||||
_log.Debug("DriverHost {Node}: discovered set for driver {Driver} unchanged ({Count} node(s)) — re-apply skipped",
|
var total = newPlans.Values.Sum(p => p.Variables.Count);
|
||||||
_localNode, msg.DriverInstanceId, plan.Variables.Count);
|
_log.Debug("DriverHost {Node}: discovered set for driver {Driver} unchanged ({Count} node(s) across {Equipment} equipment(s)) — re-apply skipped",
|
||||||
|
_localNode, msg.DriverInstanceId, total, newPlans.Count);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,6 +673,148 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans);
|
ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Partitions a multi-device driver's captured FixedTree by its (normalized) device-host folder segment
|
||||||
|
/// (<c>FolderPathSegments[1]</c>) and maps each device's subset under the candidate equipment whose
|
||||||
|
/// <see cref="EquipmentNode.DeviceHost"/> matches — the follow-up E part-2 multi-device graft. Returns
|
||||||
|
/// the per-equipment plan map (one entry per device that matched AND had at least one new variable);
|
||||||
|
/// EMPTY when nothing is graftable.
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Builds <c>normalizedHost → equipmentId</c> from the candidate <see cref="EquipmentNode"/>s
|
||||||
|
/// that carry a non-null DeviceHost. Two distinct candidates sharing a host is AMBIGUOUS — that host
|
||||||
|
/// is un-mapped (its nodes are warn-skipped) rather than grafted onto an arbitrary equipment.</item>
|
||||||
|
/// <item><b>I1 divergence:</b> a candidate WITHOUT a DeviceHost (e.g. resolved via authored tags only,
|
||||||
|
/// no device binding) simply gets no partition — the FixedTree is the device's structure, so it
|
||||||
|
/// belongs under the device-bound equipment. No crash; that candidate is just not a partition target.</item>
|
||||||
|
/// <item>If NO candidate has a DeviceHost at all there is nothing to partition on ⇒ DEGENERATE ⇒
|
||||||
|
/// warn-skip the whole driver (returns empty).</item>
|
||||||
|
/// <item>A discovered partition whose host is unmatched (or whose node has <2 folder segments, so no
|
||||||
|
/// host folder) is warn-skipped — its nodes are NOT mis-grafted; the matched partitions still graft.</item>
|
||||||
|
/// </list>
|
||||||
|
/// The device-host folder segment AND the stored DeviceHost are both run through the SAME
|
||||||
|
/// <see cref="AddressSpaceComposer.NormalizeDeviceHost"/> (single source of truth), so they compare equal
|
||||||
|
/// regardless of case/whitespace.
|
||||||
|
/// <para><b>Warn-spam taming.</b> The unmatched/ambiguous/degenerate condition is warned ONCE then
|
||||||
|
/// Debug-logged on the repeated re-discovery passes (see <see cref="ShouldWarnPartition"/>).</para>
|
||||||
|
/// <para><b>Mid-connect partition shrink (M2).</b> If a later pass yields FEWER device partitions than a
|
||||||
|
/// prior pass within the same connect, the dropped partition's routes + materialised nodes are NOT
|
||||||
|
/// actively pruned until the next full redeploy (<see cref="PushDesiredSubscriptions"/> Clears + rebuilds
|
||||||
|
/// the maps). This matches the existing "FixedTree grows-then-stabilises within a connect" assumption —
|
||||||
|
/// no mid-connect pruning is built here (out of scope).</para>
|
||||||
|
/// </summary>
|
||||||
|
private Dictionary<string, DiscoveredInjectionPlan> PartitionDiscoveredByDeviceHost(
|
||||||
|
DriverInstanceActor.DiscoveredNodesReady msg,
|
||||||
|
IReadOnlyList<string> equipmentIds,
|
||||||
|
IReadOnlySet<string> authoredRefs)
|
||||||
|
{
|
||||||
|
var driverId = msg.DriverInstanceId;
|
||||||
|
var candidateSet = equipmentIds.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// normalizedHost → equipmentId, from candidate EquipmentNodes that carry a DeviceHost. A host shared by
|
||||||
|
// two DISTINCT candidates is ambiguous: un-map it (warn-skip) so its nodes aren't grafted arbitrarily.
|
||||||
|
var hostToEquipment = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
var ambiguousHosts = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var node in _lastComposition!.EquipmentNodes)
|
||||||
|
{
|
||||||
|
if (!candidateSet.Contains(node.EquipmentId) || node.DeviceHost is null) continue;
|
||||||
|
// DeviceHost is already normalized at compose/decode time; re-normalize through the shared helper so
|
||||||
|
// the comparison is the single source of truth (idempotent — harmless if it was already normalized).
|
||||||
|
var host = AddressSpaceComposer.NormalizeDeviceHost(node.DeviceHost);
|
||||||
|
if (ambiguousHosts.Contains(host)) continue;
|
||||||
|
if (hostToEquipment.TryGetValue(host, out var existing))
|
||||||
|
{
|
||||||
|
if (!string.Equals(existing, node.EquipmentId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
hostToEquipment.Remove(host);
|
||||||
|
ambiguousHosts.Add(host);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hostToEquipment[host] = node.EquipmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEGENERATE: >1 candidate but none resolved a DeviceHost ⇒ nothing to partition on ⇒ warn-skip the
|
||||||
|
// whole driver. (Falls through the same warn-once dedup as the unmatched case.)
|
||||||
|
if (hostToEquipment.Count == 0 && ambiguousHosts.Count == 0)
|
||||||
|
{
|
||||||
|
if (ShouldWarnPartition(driverId, "degenerate"))
|
||||||
|
_log.Warning("DriverHost {Node}: driver {Driver} maps to {Count} equipments but none has a DeviceHost — discovered-node injection skipped (no device-host to partition on)",
|
||||||
|
_localNode, driverId, equipmentIds.Count);
|
||||||
|
else
|
||||||
|
_log.Debug("DriverHost {Node}: driver {Driver} still has no DeviceHost on any of {Count} equipments — skipped (repeat)",
|
||||||
|
_localNode, driverId, equipmentIds.Count);
|
||||||
|
return new Dictionary<string, DiscoveredInjectionPlan>(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partition the captured tree by its device-host folder segment (FolderPathSegments[1]); a node with
|
||||||
|
// <2 segments has no host folder (null ⇒ unmatched). Keep only nodes whose host matches a candidate.
|
||||||
|
var matchedNodes = new Dictionary<string, List<DiscoveredNode>>(StringComparer.Ordinal);
|
||||||
|
var unmatchedHosts = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var n in msg.Nodes)
|
||||||
|
{
|
||||||
|
var key = n.FolderPathSegments.Count >= 2
|
||||||
|
? AddressSpaceComposer.NormalizeDeviceHost(n.FolderPathSegments[1])
|
||||||
|
: null;
|
||||||
|
if (key is not null && hostToEquipment.ContainsKey(key))
|
||||||
|
{
|
||||||
|
if (!matchedNodes.TryGetValue(key, out var list))
|
||||||
|
matchedNodes[key] = list = new List<DiscoveredNode>();
|
||||||
|
list.Add(n);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
unmatchedHosts.Add(key ?? "(no-device-host-folder)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map each matched device's subset under its equipment. ONE device per partition ⇒ the mapper collapses
|
||||||
|
// that partition's single host folder ⇒ clean EQ-n/FOCAS/...; a plan with zero new variables (all
|
||||||
|
// shadowed by authored refs) contributes no entry.
|
||||||
|
var plans = new Dictionary<string, DiscoveredInjectionPlan>(StringComparer.Ordinal);
|
||||||
|
foreach (var (host, nodes) in matchedNodes)
|
||||||
|
{
|
||||||
|
var equipmentId = hostToEquipment[host];
|
||||||
|
var plan = DiscoveredNodeMapper.Map(equipmentId, nodes, authoredRefs);
|
||||||
|
if (plan.Variables.Count > 0) plans[equipmentId] = plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface unmatched/ambiguous hosts ONCE (then Debug on the repeated passes). The matched partitions
|
||||||
|
// above still graft regardless. When the partition came back fully clean, drop the driver's signature so
|
||||||
|
// a later recurrence re-warns.
|
||||||
|
if (unmatchedHosts.Count > 0 || ambiguousHosts.Count > 0)
|
||||||
|
{
|
||||||
|
var unmatched = string.Join(",", unmatchedHosts.OrderBy(h => h, StringComparer.Ordinal));
|
||||||
|
var ambiguous = string.Join(",", ambiguousHosts.OrderBy(h => h, StringComparer.Ordinal));
|
||||||
|
if (ShouldWarnPartition(driverId, "u:" + unmatched + "|a:" + ambiguous))
|
||||||
|
_log.Warning("DriverHost {Node}: driver {Driver}: discovered device-host partition(s) skipped — unmatched=[{Unmatched}] ambiguous=[{Ambiguous}]; matched partitions still grafted",
|
||||||
|
_localNode, driverId, unmatched, ambiguous);
|
||||||
|
else
|
||||||
|
_log.Debug("DriverHost {Node}: driver {Driver}: device-host partition(s) still skipped — unmatched=[{Unmatched}] ambiguous=[{Ambiguous}] (repeat)",
|
||||||
|
_localNode, driverId, unmatched, ambiguous);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_lastPartitionWarnSignature.Remove(driverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return plans;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Best-effort LOG-LEVEL dedup for the device-host partition diagnostics: returns true (⇒ WARN)
|
||||||
|
/// when <paramref name="conditionKey"/> is newly-seen for the driver this revision, false (⇒ DEBUG) on the
|
||||||
|
/// identical repeat passes that the ~15×/connect re-discovery produces. Folds the current revision in so a
|
||||||
|
/// redeploy re-warns once. Records the signature as a side effect. Never affects grafting behavior — only
|
||||||
|
/// the log level — so a stale entry (e.g. after a transient single↔multi candidate flip) at worst demotes
|
||||||
|
/// one duplicate warn to Debug.</summary>
|
||||||
|
private bool ShouldWarnPartition(string driverId, string conditionKey)
|
||||||
|
{
|
||||||
|
var signature = (_currentRevision?.ToString() ?? "none") + "|" + conditionKey;
|
||||||
|
var isNew = !_lastPartitionWarnSignature.TryGetValue(driverId, out var prev)
|
||||||
|
|| !string.Equals(prev, signature, StringComparison.Ordinal);
|
||||||
|
_lastPartitionWarnSignature[driverId] = signature;
|
||||||
|
return isNew;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Routing-map equality: same count + every key maps to the same NodeId. Lets
|
/// <summary>Routing-map equality: same count + every key maps to the same NodeId. Lets
|
||||||
/// <see cref="HandleDiscoveredNodes"/> skip re-applying an unchanged discovered set across the driver's
|
/// <see cref="HandleDiscoveredNodes"/> skip re-applying an unchanged discovered set across the driver's
|
||||||
/// repeated post-connect re-discovery passes (a grown/changed set differs and re-applies).</summary>
|
/// repeated post-connect re-discovery passes (a grown/changed set differs and re-applies).</summary>
|
||||||
|
|||||||
+14
@@ -91,6 +91,20 @@ public sealed class AddressSpaceComposerDeviceHostTests
|
|||||||
AddressSpaceComposer.TryExtractDeviceHost(deviceConfig).ShouldBe(expected);
|
AddressSpaceComposer.TryExtractDeviceHost(deviceConfig).ShouldBe(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The extracted-out shared normalizer (the single source of truth the FixedTree-partition path
|
||||||
|
/// reuses on a driver-discovered device-host folder segment) trims + lower-cases, and is idempotent on an
|
||||||
|
/// already-normalized value — so a segment like <c>" HOST-A:8193 "</c> matches a stored
|
||||||
|
/// <c>"host-a:8193"</c> DeviceHost.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("10.201.31.5:8193", "10.201.31.5:8193")]
|
||||||
|
[InlineData(" HOST-A:8193 ", "host-a:8193")]
|
||||||
|
[InlineData("host-a:8193", "host-a:8193")] // idempotent
|
||||||
|
[InlineData("H1", "h1")]
|
||||||
|
public void NormalizeDeviceHost_trims_and_lowercases(string raw, string expected)
|
||||||
|
{
|
||||||
|
AddressSpaceComposer.NormalizeDeviceHost(raw).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
private static Equipment NewEquipment(string id, string? driver, string? device) => new()
|
private static Equipment NewEquipment(string id, string? driver, string? device) => new()
|
||||||
{
|
{
|
||||||
EquipmentId = id,
|
EquipmentId = id,
|
||||||
|
|||||||
+177
-6
@@ -161,15 +161,17 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
update.Value.ShouldBe(42.0);
|
update.Value.ShouldBe(42.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Multi-equipment-per-driver still warn+skips (the multi-device partition is the NEXT follow-up
|
/// <summary>DEGENERATE multi-equipment case (follow-up E, part 2): a driver that resolves to MORE THAN ONE
|
||||||
/// task). A driver that resolves to MORE THAN ONE equipment injects nothing yet: no
|
/// equipment but where NONE of the candidates carries a <see cref="EquipmentNode.DeviceHost"/> has nothing
|
||||||
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is told.</summary>
|
/// to partition the FixedTree on, so the whole driver is warn-skipped (nothing grafted, no crash). Here d1
|
||||||
|
/// is bound to two equipments via two AUTHORED tags only (no Device rows ⇒ no DeviceHost), which is exactly
|
||||||
|
/// the degenerate shape: no <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is told.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Driver_mapping_to_more_than_one_equipment_still_warn_skips()
|
public void Driver_mapping_to_more_than_one_equipment_with_no_device_host_warn_skips()
|
||||||
{
|
{
|
||||||
var db = NewInMemoryDbFactory();
|
var db = NewInMemoryDbFactory();
|
||||||
var factory = new SubscribingDriverFactory("Modbus");
|
var factory = new SubscribingDriverFactory("Modbus");
|
||||||
// d1 is bound to TWO equipments via two authored tags ⇒ equipmentIds.Count == 2 ⇒ warn+skip.
|
// d1 is bound to TWO equipments via two authored tags (no devices ⇒ no DeviceHost) ⇒ degenerate ⇒ warn-skip.
|
||||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
||||||
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"),
|
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"),
|
||||||
(Equip: "EQ-2", Driver: "d1", FullName: "40002", Folder: (string?)null, Name: "speed2"));
|
(Equip: "EQ-2", Driver: "d1", FullName: "40002", Folder: (string?)null, Name: "speed2"));
|
||||||
@@ -185,10 +187,113 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
Writable: false, IsHistorized: false),
|
Writable: false, IsHistorized: false),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Nothing is grafted — the >1-equipment branch warn+skips (replaced by the multi-device task).
|
// Nothing is grafted — no candidate has a DeviceHost, so there's nothing to partition on (degenerate).
|
||||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Multi-device split (follow-up E, part 2): a driver that resolves to >1 equipment, each bound to
|
||||||
|
/// a DEVICE with a distinct <see cref="EquipmentNode.DeviceHost"/>, partitions its discovered FixedTree by
|
||||||
|
/// the (normalized) device-host folder segment and grafts each device's subset under the equipment whose
|
||||||
|
/// DeviceHost matches. Asserts (a) TWO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> (one per
|
||||||
|
/// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref
|
||||||
|
/// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1"
|
||||||
|
/// vs stored "h1" wrinkle proves the SHARED <see cref="AddressSpaceComposer.NormalizeDeviceHost"/> match.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var factory = new SubscribingDriverFactory("Modbus");
|
||||||
|
// d1 fans out to EQ-A (device host "h1") + EQ-B (device host "h2"), tag-less, bound via EquipmentNode.
|
||||||
|
var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1",
|
||||||
|
(Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"),
|
||||||
|
(Equip: "EQ-B", DeviceId: "dev-b", Host: "h2"));
|
||||||
|
|
||||||
|
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);
|
||||||
|
|
||||||
|
// Discovered nodes split across two device-host folder segments. "H1" is UPPERCASE to prove the shared
|
||||||
|
// NormalizeDeviceHost lower-cases the segment to match the stored lower-cased "h1" DeviceHost.
|
||||||
|
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
|
||||||
|
{
|
||||||
|
new DiscoveredNode(
|
||||||
|
FolderPathSegments: new[] { "FOCAS", "H1", "Identity" },
|
||||||
|
BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1",
|
||||||
|
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
|
||||||
|
new DiscoveredNode(
|
||||||
|
FolderPathSegments: new[] { "FOCAS", "h2", "Status" },
|
||||||
|
BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1",
|
||||||
|
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// (a) Each device's subset materialises under its OWN equipment — two messages, one per equipment.
|
||||||
|
var m1 = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
|
||||||
|
var m2 = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
|
||||||
|
var byEquipment = new[] { m1, m2 }.ToDictionary(m => m.EquipmentRootNodeId, m => m);
|
||||||
|
byEquipment.Keys.ShouldBe(new[] { "EQ-A", "EQ-B" }, ignoreOrder: true);
|
||||||
|
byEquipment["EQ-A"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Model");
|
||||||
|
byEquipment["EQ-B"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Run");
|
||||||
|
var eqANodeId = byEquipment["EQ-A"].Variables[0].NodeId;
|
||||||
|
var eqBNodeId = byEquipment["EQ-B"].Variables[0].NodeId;
|
||||||
|
// Single device per partition ⇒ the mapper collapses the host folder ⇒ the NodeId carries no host.
|
||||||
|
eqANodeId.ShouldStartWith("EQ-A/");
|
||||||
|
eqANodeId.ShouldNotContain("h1");
|
||||||
|
eqBNodeId.ShouldStartWith("EQ-B/");
|
||||||
|
eqBNodeId.ShouldNotContain("h2");
|
||||||
|
|
||||||
|
// (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs).
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var refs = factory.LastSubscribedRefs;
|
||||||
|
refs.ShouldNotBeNull();
|
||||||
|
refs!.ShouldContain("ft-h1-1");
|
||||||
|
refs.ShouldContain("ft-h2-1");
|
||||||
|
}, duration: Timeout);
|
||||||
|
|
||||||
|
// (c) Routing is keyed per device: h1's ref lands on EQ-A's node, h2's on EQ-B's node.
|
||||||
|
actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h1-1", 1.0, OpcUaQuality.Good, Ts));
|
||||||
|
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(Timeout).NodeId.ShouldBe(eqANodeId);
|
||||||
|
actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h2-1", 2.0, OpcUaQuality.Good, Ts));
|
||||||
|
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(Timeout).NodeId.ShouldBe(eqBNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Multi-device unmatched warn-skip (follow-up E, part 2): a discovered device-host partition with
|
||||||
|
/// NO matching equipment is warn-skipped (NOT mis-grafted), while the matched partitions still graft. Here
|
||||||
|
/// d1 fans out to EQ-A("h1") + EQ-B("h2"), but a third discovered partition "h3" matches no equipment: only
|
||||||
|
/// EQ-A + EQ-B materialise (no third), and the unmatched ref routes nowhere (no crash).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var factory = new SubscribingDriverFactory("Modbus");
|
||||||
|
var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1",
|
||||||
|
(Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"),
|
||||||
|
(Equip: "EQ-B", DeviceId: "dev-b", Host: "h2"));
|
||||||
|
|
||||||
|
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);
|
||||||
|
|
||||||
|
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
|
||||||
|
{
|
||||||
|
new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h1", "Identity" },
|
||||||
|
BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1",
|
||||||
|
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
|
||||||
|
new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h2", "Status" },
|
||||||
|
BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1",
|
||||||
|
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
|
||||||
|
new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h3", "Identity" },
|
||||||
|
BrowseName: "Ghost", DisplayName: "Ghost", FullReference: "ft-h3-1",
|
||||||
|
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Exactly TWO materialise (EQ-A, EQ-B); the unmatched "h3" grafts nowhere ⇒ no third materialise.
|
||||||
|
var m1 = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
|
||||||
|
var m2 = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
|
||||||
|
new[] { m1.EquipmentRootNodeId, m2.EquipmentRootNodeId }.ShouldBe(new[] { "EQ-A", "EQ-B" }, ignoreOrder: true);
|
||||||
|
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||||
|
|
||||||
|
// The ghost ref was never materialised, so a value for it routes nowhere — dropped, not a crash.
|
||||||
|
actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h3-1", 9.0, OpcUaQuality.Good, Ts));
|
||||||
|
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Guard: a <see cref="DriverInstanceActor.DiscoveredNodesReady"/> arriving BEFORE any deployment
|
/// <summary>Guard: a <see cref="DriverInstanceActor.DiscoveredNodesReady"/> arriving BEFORE any deployment
|
||||||
/// is applied (<c>_lastComposition</c> still null) is ignored — nothing is materialised on the publish
|
/// is applied (<c>_lastComposition</c> still null) is ignored — nothing is materialised on the publish
|
||||||
/// side (the equipment can't be resolved without a composition).</summary>
|
/// side (the equipment can't be resolved without a composition).</summary>
|
||||||
@@ -970,6 +1075,72 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds a Sealed deployment whose artifact binds ONE driver to MULTIPLE equipments, each via a
|
||||||
|
/// distinct <c>Device</c> row whose <c>DeviceConfig</c> carries a <c>HostAddress</c> — so each
|
||||||
|
/// <see cref="EquipmentNode"/> resolves a distinct <see cref="EquipmentNode.DeviceHost"/> (the shape
|
||||||
|
/// the multi-device FixedTree partition keys on). No authored tags (tag-less): the equipments are
|
||||||
|
/// resolved purely from <c>EquipmentNodes</c>. The driver row is non-Windows-only ("Modbus", Enabled)
|
||||||
|
/// so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned.
|
||||||
|
/// </summary>
|
||||||
|
private static DeploymentId SeedDeploymentWithMultiDeviceEquipments(
|
||||||
|
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev, string driverId,
|
||||||
|
params (string Equip, string DeviceId, string Host)[] equipments)
|
||||||
|
{
|
||||||
|
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
Namespaces = new[]
|
||||||
|
{
|
||||||
|
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
||||||
|
},
|
||||||
|
DriverInstances = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
DriverInstanceRowId = Guid.NewGuid(),
|
||||||
|
DriverInstanceId = driverId,
|
||||||
|
Name = driverId,
|
||||||
|
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
|
||||||
|
Enabled = true,
|
||||||
|
DriverConfig = "{}",
|
||||||
|
NamespaceId = "ns-eq",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Each device carries a HostAddress so EquipmentNode.DeviceHost resolves (via the shared extractor).
|
||||||
|
Devices = equipments.Select(e => new
|
||||||
|
{
|
||||||
|
DeviceId = e.DeviceId,
|
||||||
|
DriverInstanceId = driverId,
|
||||||
|
Name = e.DeviceId,
|
||||||
|
DeviceConfig = JsonSerializer.Serialize(new { HostAddress = e.Host }),
|
||||||
|
}).ToArray(),
|
||||||
|
// Each equipment binds to the driver AND a device — the multi-device resolution path.
|
||||||
|
Equipment = equipments.Select(e => new
|
||||||
|
{
|
||||||
|
EquipmentId = e.Equip,
|
||||||
|
Name = e.Equip,
|
||||||
|
UnsLineId = (string?)null,
|
||||||
|
DriverInstanceId = driverId,
|
||||||
|
DeviceId = e.DeviceId,
|
||||||
|
}).ToArray(),
|
||||||
|
Tags = Array.Empty<object>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
var id = DeploymentId.NewId();
|
||||||
|
using var ctx = db.CreateDbContext();
|
||||||
|
ctx.Deployments.Add(new Deployment
|
||||||
|
{
|
||||||
|
DeploymentId = id.Value,
|
||||||
|
RevisionHash = rev.Value,
|
||||||
|
Status = DeploymentStatus.Sealed,
|
||||||
|
CreatedBy = "test",
|
||||||
|
SealedAtUtc = DateTime.UtcNow,
|
||||||
|
ArtifactBlob = artifact,
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Factory producing a single shared <see cref="SubscribableStubDriver"/> for the supported
|
/// <summary>Factory producing a single shared <see cref="SubscribableStubDriver"/> for the supported
|
||||||
/// type, exposing its most-recent subscribed reference set for assertions (mirrors
|
/// type, exposing its most-recent subscribed reference set for assertions (mirrors
|
||||||
/// <c>DriverHostActorWriteRoutingTests.RecordingDriverFactory</c>, but the driver is
|
/// <c>DriverHostActorWriteRoutingTests.RecordingDriverFactory</c>, but the driver is
|
||||||
|
|||||||
Reference in New Issue
Block a user