using Akka.Actor; using Akka.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Core.Scripting; using ZB.MOM.WW.OtOpcUa.Core.VirtualTags; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Health; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; namespace ZB.MOM.WW.OtOpcUa.Runtime; public static class ServiceCollectionExtensions { public const string DriverRole = "driver"; public const string DriverHostActorName = "driver-host"; public const string DbHealthProbeActorName = "db-health"; public const string HistorianAdapterActorName = "historian-adapter"; public const string DependencyMuxActorName = "dependency-mux"; public const string OpcUaPublishActorName = "opcua-publish"; public const string PeerProbeSupervisorName = "peer-probe-supervisor"; /// /// Registers shared runtime services. Currently binds /// to as the default; production deployments /// override this with SqliteStoreAndForwardSink wrapping WonderwareHistorianClient. /// Call this BEFORE AddAkka. /// /// The service collection to register with. public static IServiceCollection AddOtOpcUaRuntime(this IServiceCollection services) { services.TryAddSingleton(NullAlarmHistorianSink.Instance); services.TryAddSingleton(NullHistorianDataSource.Instance); // VirtualTag historization sink. Null default — the durable AVEVA sink is infra-gated (there is // no live-data historian write RPC). TryAddSingleton so a deployment that bound a real // IHistoryWriter earlier wins. services.TryAddSingleton(NullHistoryWriter.Instance); services.TryAddSingleton(NullDriverFactory.Instance); services.TryAddSingleton(NullOpcUaAddressSpaceSink.Instance); services.TryAddSingleton(NullServiceLevelPublisher.Instance); services.TryAddSingleton(); return services; } /// /// Config-gated durable alarm-historian sink. When the AlarmHistorian section has /// Enabled=true, registers a (draining via the /// -supplied writer) as the , /// overriding the default. Otherwise a no-op (Null stays). /// The writer is injected so the durable downstream (Wonderware named-pipe client) can be supplied /// by the Host, which is the only project that references it. /// /// The service collection to register with. /// The configuration carrying the AlarmHistorian section. /// /// Factory the Host supplies to build the concrete /// (the Wonderware named-pipe client) from the bound options + the resolving provider. /// /// The same instance for chaining. public static IServiceCollection AddAlarmHistorian( this IServiceCollection services, IConfiguration configuration, Func writerFactory) { var opts = configuration.GetSection(AlarmHistorianOptions.SectionName).Get(); if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime foreach (var warning in opts.Validate()) Serilog.Log.Logger.ForContext().Warning("Historian config: {HistorianConfigWarning}", warning); services.AddSingleton(sp => { // SqliteStoreAndForwardSink takes a Serilog ILogger (not Microsoft.Extensions.Logging). // Resolve it off the host's configured static logger so the drain worker's WARN/INFO // lines land in the same sinks as the rest of the process. var sink = new SqliteStoreAndForwardSink( opts.DatabasePath, writerFactory(opts, sp), Serilog.Log.Logger.ForContext(), batchSize: opts.BatchSize, capacity: opts.Capacity, deadLetterRetention: TimeSpan.FromDays(opts.DeadLetterRetentionDays), maxAttempts: opts.MaxAttempts); sink.StartDrainLoop(TimeSpan.FromSeconds(opts.DrainIntervalSeconds)); return sink; }); return services; } /// /// Config-gated server-side HistoryRead backend. When the ServerHistorian section has /// Enabled=true, registers the -supplied /// (the read-only Wonderware client) overriding the /// default from . Otherwise /// a no-op (the Null default stays and the node manager's HistoryRead returns /// GoodNoData-empty). The data source is injected so the Wonderware client can be supplied /// by the Host, which is the only project that references it. /// /// The service collection to register with. /// The configuration carrying the ServerHistorian section. /// /// Factory the Host supplies to build the concrete read /// (the Wonderware client) from the bound options + the resolving provider. /// /// The same instance for chaining. public static IServiceCollection AddServerHistorian( this IServiceCollection services, IConfiguration configuration, Func dataSourceFactory) { var opts = configuration.GetSection(ServerHistorianOptions.SectionName).Get(); if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime foreach (var warning in opts.Validate()) Serilog.Log.Logger.ForContext().Warning("ServerHistorian config: {ServerHistorianConfigWarning}", warning); // Last-registration-wins over the TryAddSingleton Null default seeded by AddOtOpcUaRuntime. services.AddSingleton(sp => dataSourceFactory(opts, sp)); return services; } /// /// Spawns the per-node driver-role actors on the host's : /// (one per node), /// (consumed by the health endpoint + redundancy calc), and /// wrapping the registered . /// /// Mirror of WithOtOpcUaControlPlaneSingletons for the driver role. Both must /// be registered on the same as the cluster /// bootstrap so the actors share the host's ActorSystem. /// /// Wire from the fused Host's Program.cs when the node carries the driver role: /// /// services.AddOtOpcUaRuntime(); /// services.AddAkka("otopcua", (ab, sp) => { ab.WithOtOpcUaClusterBootstrap(sp); if (hasDriver) ab.WithOtOpcUaRuntimeActors(); }); /// /// /// The Akka configuration builder. public static AkkaConfigurationBuilder WithOtOpcUaRuntimeActors(this AkkaConfigurationBuilder builder) { // Production cluster HOCON (akka.conf) carries this dispatcher block, but consumers that // bootstrap their own HOCON (e.g. ServiceCollectionExtensionsTests) wouldn't pick it up // — OpcUaPublishActor.Props pins itself to opcua-synchronized-dispatcher and Akka throws // ConfigurationException if it doesn't exist. Prepend a fallback so the runtime extension // is self-contained. builder.AddHocon(@" opcua-synchronized-dispatcher { type = ""PinnedDispatcher"" executor = ""thread-pool-executor"" throughput = 1 } ", HoconAddMode.Prepend); builder.WithActors((system, registry, resolver) => { var dbFactory = resolver.GetService>(); var roleInfo = resolver.GetService(); // Fallback to Null* if AddOtOpcUaRuntime wasn't called (e.g., test harnesses). var historianSink = resolver.GetService() ?? NullAlarmHistorianSink.Instance; var driverFactory = resolver.GetService() ?? NullDriverFactory.Instance; var addressSpaceSink = resolver.GetService() ?? NullOpcUaAddressSpaceSink.Instance; var serviceLevel = resolver.GetService() ?? NullServiceLevelPublisher.Instance; var loggerFactory = resolver.GetService() ?? NullLoggerFactory.Instance; var healthPublisher = resolver.GetService() ?? NullDriverHealthPublisher.Instance; // Root script logger backs the ScriptedAlarm host's engine + script logging. Registered in // Host DI inside the hasDriver block; may be absent in some role configs / test harnesses, // in which case the DriverHostActor gracefully skips spawning the ScriptedAlarm host. var scriptRootLogger = resolver.GetService(); // Production evaluator is the Host's RoslynVirtualTagEvaluator (registered as // IVirtualTagEvaluator); fall back to the null evaluator for test harnesses that don't // register one (VirtualTagActor children then evaluate to nothing). var virtualTagEvaluator = resolver.GetService(); if (virtualTagEvaluator is null) { loggerFactory.CreateLogger("ZB.MOM.WW.OtOpcUa.Runtime.ServiceCollectionExtensions") .LogWarning("IVirtualTagEvaluator not registered; Equipment VirtualTags will evaluate to NoChange (no live values). Expected only in test harnesses — driver-role nodes should register RoslynVirtualTagEvaluator."); virtualTagEvaluator = NullVirtualTagEvaluator.Instance; } // VirtualTag historization sink threaded to the spawned VirtualTagHostActor. Null default // (durable AVEVA sink is infra-gated); a deployment binding a real IHistoryWriter overrides. var historyWriter = resolver.GetService() ?? NullHistoryWriter.Instance; var dbHealth = system.ActorOf( DbHealthProbeActor.Props(dbFactory), DbHealthProbeActorName); registry.Register(dbHealth); // Dependency mux must be spawned before DriverHostActor so the host can forward // AttributeValuePublished into it from the very first driver spawn. var mux = system.ActorOf(DependencyMuxActor.Props(), DependencyMuxActorName); registry.Register(mux); // OPC UA publish actor — pinned dispatcher, owns the address-space side of the // pipeline. Phase7Applier is constructed here so the actor + applier share the // same sink reference (when DeferredAddressSpaceSink swaps later, both see it). var applier = new Phase7Applier(addressSpaceSink, loggerFactory.CreateLogger()); var publishActor = system.ActorOf( OpcUaPublishActor.Props( sink: addressSpaceSink, serviceLevel: serviceLevel, localNode: roleInfo.LocalNode, dbFactory: dbFactory, applier: applier, dbHealthProbe: dbHealth), OpcUaPublishActorName); registry.Register(publishActor); // Per-node peer-probe supervisor — keeps one OPC UA TCP probe per OTHER non-Detached // driver node, so every node is continuously probed by all its peers. OpcUaPublishActor // consumes the resulting probe verdicts to learn this node's own reachability. var peerProbes = system.ActorOf( PeerProbeSupervisor.Props(roleInfo.LocalNode), PeerProbeSupervisorName); registry.Register(peerProbes); var driverHost = system.ActorOf( DriverHostActor.Props(dbFactory, roleInfo.LocalNode, coordinator: null, driverFactory: driverFactory, localRoles: roleInfo.LocalRoles, dependencyMux: mux, opcUaPublishActor: publishActor, healthPublisher: healthPublisher, virtualTagEvaluator: virtualTagEvaluator, historyWriter: historyWriter, loggerFactory: loggerFactory, scriptRootLogger: scriptRootLogger), DriverHostActorName); registry.Register(driverHost); var historian = system.ActorOf( HistorianAdapterActor.Props(historianSink, roleInfo.LocalNode), HistorianAdapterActorName); registry.Register(historian); }); return builder; } } /// Marker key types used by Akka.Hosting to resolve runtime actors from the registry. public sealed class DriverHostActorKey { } public sealed class DbHealthProbeActorKey { } public sealed class HistorianAdapterActorKey { } public sealed class DependencyMuxActorKey { } public sealed class OpcUaPublishActorKey { } /// Marker key for the per-node PeerProbeSupervisor. public sealed class PeerProbeSupervisorKey { }