c74ccc55b5
`SparkplugCodec` handled scalars; `DataSet`, `Template` and `PropertySet` were skipped
with a warning. They now flatten to addressable tags (owner decision, recorded on the
issue):
Template → sibling metrics, `/`-joined, recursively: Motor → Motor/Speed, Motor/Stator/Temp
DataSet → one metric per cell, {column}/{rowIndex}: Recipe → Recipe/Step/0, Recipe/Target/1
PropertySet → node metadata, never tags — it describes a metric rather than being one
Flattening was chosen over OPC UA Structures because flattened scalars are consumable by
any client with no type dictionary, and the result mirrors how the `/raw` tree works.
**The row index is always present, including on row 0.** Emitting a bare column name for
a single-row table and an indexed one otherwise would make a tag's name depend on how many
rows the plant published — a table growing from one row to two would rename every tag
already authored against it.
**A DATA metric carries the parent's alias and no name**, so member names cannot be
composed until the alias table has resolved the parent. Expansion therefore happens in
`SparkplugIngestor.PublishStructuredMembers` — after resolution, at the single funnel both
the DATA and birth-value paths already pass through — not at decode time. The alias table
installs a binding per member so browse enumerates them and a DDATA member that omits its
datatype falls back to the birth's (guessing from the CLR type would read a signed Int32 of
-42 out of its unsigned field as 4294967254).
**Skip-and-warn survives, and refusal is whole** — the explicit requirement on the issue.
A template definition, an unnamed member, an extension value, a column/type mismatch, a
short row, or a payload past MaxDepth(5)/MaxMembers(512) refuses the ENTIRE metric. A
silently half-flattened UDT looks exactly like a complete one in the picker.
The browse picker offers the members and deliberately NOT the structured parent, which has
no value and no servable datatype. The rediscovery signature likewise tracks members rather
than the parent, or a UDT gaining a member would leave the signature unmoved.
Member paths contain `/`, which #509 already solved: the slashed name travels intact as
`metricName` while the derived tag Name is coerced to a legal RawPath segment.
Live-gated 3/3 against the Mosquitto fixture with the project-owned simulator extended to
publish a nested Template and a 2x2 DataSet. Falsified at three points: dropping the member
bindings, not expanding the parent, and refusing to flatten templates each turn exactly the
expected tests red.
Also fixes a regression from the #511 commit in the same branch: `AddOtOpcUaDriverFactories`
resolved `IDriverPrimaryRoleSource` with `GetRequiredService` but only `AddOtOpcUaRuntime`
registered it, so a graph wiring driver factories without the full runtime failed at
resolution. Two Host integration suites do exactly that and caught it; both extensions now
TryAdd the same concrete `RedundancyRoleView`, so the registration is order-independent and
the fail-loud resolve is kept.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
191 lines
12 KiB
C#
191 lines
12 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
|
|
|
// Probe type aliases — keep the using list concise.
|
|
using ModbusProbe = Driver.Modbus.ModbusDriverProbe;
|
|
using AbCipProbe = Driver.AbCip.AbCipDriverProbe;
|
|
using AbLegacyProbe = Driver.AbLegacy.AbLegacyDriverProbe;
|
|
using S7Probe = Driver.S7.S7DriverProbe;
|
|
using TwinCATProbe = Driver.TwinCAT.TwinCATDriverProbe;
|
|
using FocasProbe = Driver.FOCAS.FocasDriverProbe;
|
|
using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe;
|
|
using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe;
|
|
using CalculationProbe = Driver.Calculation.CalculationDriverProbe;
|
|
using SqlProbe = Driver.Sql.SqlDriverProbe;
|
|
using MqttProbe = Driver.Mqtt.MqttDriverProbe;
|
|
using MTConnectProbe = Driver.MTConnect.MTConnectDriverProbe;
|
|
|
|
/// <summary>
|
|
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
|
|
/// extension into a single <see cref="DriverFactoryRegistry"/> singleton and binds the
|
|
/// v2 <see cref="IDriverFactory"/> abstraction to a <see cref="DriverFactoryRegistryAdapter"/>
|
|
/// over it. Replaces the F7 seam's <c>NullDriverFactory</c> default so deploys actually
|
|
/// materialise real <see cref="IDriver"/> instances on driver-role nodes.
|
|
///
|
|
/// The factory registry is skipped on admin-only nodes — they never run drivers, so it doesn't
|
|
/// need to exist (Program.cs guards via the <c>hasDriver</c> flag). The driver <em>probe</em>
|
|
/// set is the exception: it backs the AdminUI Test Connect button and so must also be wired on
|
|
/// admin nodes — see <see cref="AddOtOpcUaDriverProbes"/>.
|
|
/// </summary>
|
|
public static class DriverFactoryBootstrap
|
|
{
|
|
/// <summary>
|
|
/// Register the cross-platform driver factories + bind <see cref="IDriverFactory"/>.
|
|
/// Must be called BEFORE <c>services.AddAkka</c> so the runtime extension can resolve
|
|
/// <see cref="IDriverFactory"/> from DI when spawning <c>DriverHostActor</c>.
|
|
/// </summary>
|
|
/// <param name="services">The service collection to register driver factories with.</param>
|
|
/// <returns>The same service collection, for chaining.</returns>
|
|
public static IServiceCollection AddOtOpcUaDriverFactories(this IServiceCollection services)
|
|
{
|
|
// Gitea #511: the Primary data-plane verdict the MQTT driver's Sparkplug host claim is gated on.
|
|
//
|
|
// Registered HERE as well as in AddOtOpcUaRuntime, and both sides TryAdd the same concrete
|
|
// RedundancyRoleView, so whichever extension runs first creates it and the other resolves the
|
|
// SAME instance. That is deliberately not a defensive duplicate: the resolve below is
|
|
// GetRequiredService (a null would silently fall the driver back to "claim unconditionally",
|
|
// which on a redundant pair is the dual-claim this mechanism exists to prevent), so without
|
|
// this line the registration becomes order-dependent on two extensions a caller is free to
|
|
// invoke in either order — and any graph that wires driver factories without the full runtime
|
|
// fails at resolution. Two Host integration suites do exactly that, and caught it.
|
|
services.TryAddSingleton<RedundancyRoleView>();
|
|
services.TryAddSingleton<IDriverPrimaryRoleSource>(sp => sp.GetRequiredService<RedundancyRoleView>());
|
|
|
|
services.AddSingleton<DriverFactoryRegistry>(sp =>
|
|
{
|
|
var registry = new DriverFactoryRegistry();
|
|
var loggerFactory = sp.GetService<ILoggerFactory>();
|
|
// The calc driver needs the root script logger so a script failure fans out onto the
|
|
// script-logs topic; resolve it here (null on nodes without the script pipeline wired).
|
|
var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>();
|
|
// The Galaxy driver resolves its Gateway.ApiKeySecretRef secret: arm through the
|
|
// shared ISecretResolver (registered unconditionally by AddZbSecrets on every node).
|
|
var secretResolver = sp.GetRequiredService<ISecretResolver>();
|
|
// Gitea #511: the MQTT driver's Sparkplug primary-host claim is Primary-gated, and unlike
|
|
// every other gated operation it is a STANDING assertion on the driver's own connection —
|
|
// there is no inbound message for DriverHostActor to intercept — so the verdict has to
|
|
// reach the driver itself. Registered unconditionally on driver-role nodes by
|
|
// AddOtOpcUaRuntime. GetRequiredService, not GetService: a null here silently falls the
|
|
// driver back to "claim unconditionally", which on a redundant pair is the dual-claim this
|
|
// whole mechanism exists to prevent, and it would fail as a plant outage rather than as a
|
|
// startup error.
|
|
var primaryRoleSource = sp.GetRequiredService<IDriverPrimaryRoleSource>();
|
|
Register(registry, loggerFactory, secretResolver, primaryRoleSource, scriptRoot);
|
|
return registry;
|
|
});
|
|
services.AddSingleton<IDriverFactory>(sp =>
|
|
new DriverFactoryRegistryAdapter(sp.GetRequiredService<DriverFactoryRegistry>()));
|
|
|
|
// Phase 6.1 resilience infrastructure — the process-singleton tracker + Polly pipeline builder,
|
|
// and the per-driver-instance invoker factory the DriverHostActor uses to wrap every capability
|
|
// call. Registered here (driver nodes) because the factory needs DriverFactoryRegistry.GetTier;
|
|
// admin-only nodes skip this and the runtime falls back to the pass-through factory.
|
|
services.AddSingleton<DriverResilienceStatusTracker>();
|
|
services.AddSingleton<DriverResiliencePipelineBuilder>(sp =>
|
|
new DriverResiliencePipelineBuilder(
|
|
timeProvider: null, // TimeProvider.System
|
|
statusTracker: sp.GetRequiredService<DriverResilienceStatusTracker>(),
|
|
logger: sp.GetService<ILoggerFactory>()?.CreateLogger("ZB.MOM.WW.OtOpcUa.Core.Resilience.DriverResiliencePipelineBuilder")));
|
|
services.AddSingleton<IDriverCapabilityInvokerFactory>(sp =>
|
|
{
|
|
var registry = sp.GetRequiredService<DriverFactoryRegistry>();
|
|
return new DriverCapabilityInvokerFactory(
|
|
sp.GetRequiredService<DriverResiliencePipelineBuilder>(),
|
|
sp.GetRequiredService<DriverResilienceStatusTracker>(),
|
|
registry.GetTier,
|
|
logger: sp.GetService<ILoggerFactory>()?.CreateLogger("ZB.MOM.WW.OtOpcUa.Core.Resilience.DriverCapabilityInvokerFactory"));
|
|
});
|
|
|
|
// The tracker's operator-facing reader: a periodic DPS publisher that mirrors each snapshot to the
|
|
// AdminUI resilience panel (R2-10 — closes the "tracker fed by everything, read by nothing" gap).
|
|
// Lives on driver nodes exactly where the tracker exists. The lazy Func<ActorSystem> is idempotent
|
|
// with the one Program.cs registers for the alarm-command router.
|
|
services.TryAddSingleton<Func<Akka.Actor.ActorSystem>>(sp => () => sp.GetRequiredService<Akka.Actor.ActorSystem>());
|
|
services.AddHostedService<DriverResilienceStatusPublisherService>();
|
|
|
|
// Driver nodes also carry the probe set so a fused admin,driver node has it; the admin-only
|
|
// case is covered by Program.cs calling AddOtOpcUaDriverProbes() in the hasAdmin block.
|
|
services.AddOtOpcUaDriverProbes();
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Register one <see cref="IDriverProbe"/> per driver type. These back the AdminUI's
|
|
/// "Test Connect" button: the <c>admin-operations</c> cluster singleton resolves
|
|
/// <see cref="IEnumerable{T}"/> of <see cref="IDriverProbe"/> and dispatches by DriverType.
|
|
/// <para>
|
|
/// That singleton is role-pinned to <c>admin</c>, so this MUST be wired on admin nodes —
|
|
/// including admin-only nodes that lack the <c>driver</c> role (e.g. the MAIN cluster's
|
|
/// admin-a/admin-b). Probes are lightweight (cheap connect, no persistent state) and don't
|
|
/// need the driver-factory registry, so they register independently of
|
|
/// <see cref="AddOtOpcUaDriverFactories"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Uses <c>TryAddEnumerable</c> so a fused admin,driver node — which reaches this from both
|
|
/// the driver-factory path and the admin path — registers each probe exactly once. A
|
|
/// duplicate would make the singleton's <c>ToDictionary(p => p.DriverType)</c> throw.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="services">The service collection to register driver probes with.</param>
|
|
/// <returns>The same service collection, for chaining.</returns>
|
|
public static IServiceCollection AddOtOpcUaDriverProbes(this IServiceCollection services)
|
|
{
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, ModbusProbe>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbCipProbe>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbLegacyProbe>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, S7Probe>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, TwinCATProbe>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, FocasProbe>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, OpcUaProbe>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, GalaxyProbe>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, CalculationProbe>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MqttProbe>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MTConnectProbe>());
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invoke every cross-platform driver's <c>Register</c> extension. New driver assemblies
|
|
/// get added here — one line per type. ShouldStub() in <c>DriverInstanceActor</c> still
|
|
/// handles platform/role-dependent stubbing (e.g. Galaxy on macOS), so registering a
|
|
/// factory here doesn't mean it always runs in production.
|
|
/// </summary>
|
|
private static void Register(
|
|
DriverFactoryRegistry registry,
|
|
ILoggerFactory? loggerFactory,
|
|
ISecretResolver secretResolver,
|
|
IDriverPrimaryRoleSource primaryRoleSource,
|
|
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
|
{
|
|
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
|
|
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
|
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
|
|
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
|
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
|
|
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
|
Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory, primaryRoleSource);
|
|
// Tier A (the Register default): fully managed — HttpClient + System.Xml.Linq, no native SDK,
|
|
// no COM. Tier C is the only tier that arms process-level recycle (MemoryRecycle hard-breach /
|
|
// ScheduledRecycleScheduler), which would be wrong here: the driver is in-process and killing
|
|
// it kills every OPC UA session. The long-lived /sample stream does not argue for a slower
|
|
// tier either — SubscribeAsync returns synchronously after starting the pump on a background
|
|
// task, so the Tier A 5s Subscribe budget never covers the stream's lifetime.
|
|
Driver.MTConnect.MTConnectDriverFactoryExtensions.Register(registry, loggerFactory);
|
|
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
|
|
Driver.S7.S7DriverFactoryExtensions.Register(registry);
|
|
Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);
|
|
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
|
|
}
|
|
}
|