Renames all 11 projects (5 src + 6 tests), the .slnx solution file, all source-file namespaces, all axaml namespace references, and all v1 documentation references in CLAUDE.md and docs/*.md (excluding docs/v2/ which is already in OtOpcUa form). Also updates the TopShelf service registration name from "LmxOpcUa" to "OtOpcUa" per Phase 0 Task 0.6.
Preserves runtime identifiers per Phase 0 Out-of-Scope rules to avoid breaking v1/v2 client trust during coexistence: OPC UA `ApplicationUri` defaults (`urn:{GalaxyName}:LmxOpcUa`), server `EndpointPath` (`/LmxOpcUa`), `ServerName` default (feeds cert subject CN), `MxAccessConfiguration.ClientName` default (defensive — stays "LmxOpcUa" for MxAccess audit-trail consistency), client OPC UA identifiers (`ApplicationName = "LmxOpcUaClient"`, `ApplicationUri = "urn:localhost:LmxOpcUaClient"`, cert directory `%LocalAppData%\LmxOpcUaClient\pki\`), and the `LmxOpcUaServer` class name (class rename out of Phase 0 scope per Task 0.5 sed pattern; happens in Phase 1 alongside `LmxNodeManager → GenericDriverNodeManager` Core extraction). 23 LmxOpcUa references retained, all enumerated and justified in `docs/v2/implementation/exit-gate-phase-0.md`.
Build clean: 0 errors, 30 warnings (lower than baseline 167). Tests at strict improvement over baseline: 821 passing / 1 failing vs baseline 820 / 2 (one flaky pre-existing failure passed this run; the other still fails — both pre-existing and unrelated to the rename). `Client.UI.Tests`, `Historian.Aveva.Tests`, `Client.Shared.Tests`, `IntegrationTests` all match baseline exactly. Exit gate compliance results recorded in `docs/v2/implementation/exit-gate-phase-0.md` with all 7 checks PASS or DEFERRED-to-PR-review (#7 service install verification needs Windows service permissions on the reviewer's box).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
181 lines
7.8 KiB
C#
181 lines
7.8 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Reflection;
|
|
using Serilog;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
|
|
{
|
|
/// <summary>
|
|
/// Result of the most recent historian plugin load attempt.
|
|
/// </summary>
|
|
public enum HistorianPluginStatus
|
|
{
|
|
/// <summary>Historian.Enabled is false; TryLoad was not called.</summary>
|
|
Disabled,
|
|
/// <summary>Plugin DLL was not present in the Historian/ subfolder.</summary>
|
|
NotFound,
|
|
/// <summary>Plugin file exists but could not be loaded or instantiated.</summary>
|
|
LoadFailed,
|
|
/// <summary>Plugin loaded and an IHistorianDataSource was constructed.</summary>
|
|
Loaded
|
|
}
|
|
|
|
/// <summary>
|
|
/// Structured outcome of a <see cref="HistorianPluginLoader.TryLoad"/> or
|
|
/// <see cref="HistorianPluginLoader.MarkDisabled"/> call, used by the status dashboard.
|
|
/// </summary>
|
|
public sealed class HistorianPluginOutcome
|
|
{
|
|
public HistorianPluginOutcome(HistorianPluginStatus status, string pluginPath, string? error)
|
|
{
|
|
Status = status;
|
|
PluginPath = pluginPath;
|
|
Error = error;
|
|
}
|
|
|
|
public HistorianPluginStatus Status { get; }
|
|
public string PluginPath { get; }
|
|
public string? Error { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads the Wonderware historian plugin assembly from the Historian/ subfolder next to
|
|
/// the host executable. Used so the aahClientManaged SDK is not needed on hosts that run
|
|
/// with Historian.Enabled=false.
|
|
/// </summary>
|
|
public static class HistorianPluginLoader
|
|
{
|
|
private const string PluginSubfolder = "Historian";
|
|
private const string PluginAssemblyName = "ZB.MOM.WW.OtOpcUa.Historian.Aveva";
|
|
private const string PluginEntryType = "ZB.MOM.WW.OtOpcUa.Historian.Aveva.AvevaHistorianPluginEntry";
|
|
private const string PluginEntryMethod = "Create";
|
|
|
|
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(HistorianPluginLoader));
|
|
private static readonly object ResolverGate = new object();
|
|
private static bool _resolverInstalled;
|
|
private static string? _resolvedProbeDirectory;
|
|
|
|
/// <summary>
|
|
/// Gets the outcome of the most recent load attempt (or <see cref="HistorianPluginStatus.Disabled"/>
|
|
/// if the loader has never been invoked). The dashboard reads this to distinguish "disabled",
|
|
/// "plugin missing", and "plugin crashed".
|
|
/// </summary>
|
|
public static HistorianPluginOutcome LastOutcome { get; private set; }
|
|
= new HistorianPluginOutcome(HistorianPluginStatus.Disabled, string.Empty, null);
|
|
|
|
/// <summary>
|
|
/// Records that the historian plugin is disabled by configuration. Called by
|
|
/// <c>OpcUaService</c> when <c>Historian.Enabled=false</c> so the status dashboard can
|
|
/// report the exact reason history is unavailable.
|
|
/// </summary>
|
|
public static void MarkDisabled()
|
|
{
|
|
LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.Disabled, string.Empty, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to load the historian plugin and construct an <see cref="IHistorianDataSource"/>.
|
|
/// Returns null on any failure so the server can continue with history unsupported. The
|
|
/// specific reason is published on <see cref="LastOutcome"/>.
|
|
/// </summary>
|
|
public static IHistorianDataSource? TryLoad(HistorianConfiguration config)
|
|
{
|
|
var pluginDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, PluginSubfolder);
|
|
var pluginPath = Path.Combine(pluginDirectory, PluginAssemblyName + ".dll");
|
|
|
|
if (!File.Exists(pluginPath))
|
|
{
|
|
Log.Warning(
|
|
"Historian plugin not found at {PluginPath} — history read operations will return BadHistoryOperationUnsupported",
|
|
pluginPath);
|
|
LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.NotFound, pluginPath, null);
|
|
return null;
|
|
}
|
|
|
|
EnsureAssemblyResolverInstalled(pluginDirectory);
|
|
|
|
try
|
|
{
|
|
var assembly = Assembly.LoadFrom(pluginPath);
|
|
var entryType = assembly.GetType(PluginEntryType, throwOnError: false);
|
|
if (entryType == null)
|
|
{
|
|
Log.Warning("Historian plugin {PluginPath} does not expose {EntryType}", pluginPath, PluginEntryType);
|
|
LastOutcome = new HistorianPluginOutcome(
|
|
HistorianPluginStatus.LoadFailed, pluginPath,
|
|
$"Plugin assembly does not expose entry type {PluginEntryType}");
|
|
return null;
|
|
}
|
|
|
|
var create = entryType.GetMethod(PluginEntryMethod, BindingFlags.Public | BindingFlags.Static);
|
|
if (create == null)
|
|
{
|
|
Log.Warning("Historian plugin entry type {EntryType} missing static {Method}", PluginEntryType, PluginEntryMethod);
|
|
LastOutcome = new HistorianPluginOutcome(
|
|
HistorianPluginStatus.LoadFailed, pluginPath,
|
|
$"Plugin entry type {PluginEntryType} is missing a public static {PluginEntryMethod} method");
|
|
return null;
|
|
}
|
|
|
|
var result = create.Invoke(null, new object[] { config });
|
|
if (result is IHistorianDataSource dataSource)
|
|
{
|
|
Log.Information("Historian plugin loaded from {PluginPath}", pluginPath);
|
|
LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.Loaded, pluginPath, null);
|
|
return dataSource;
|
|
}
|
|
|
|
Log.Warning("Historian plugin {PluginPath} returned an object that does not implement IHistorianDataSource", pluginPath);
|
|
LastOutcome = new HistorianPluginOutcome(
|
|
HistorianPluginStatus.LoadFailed, pluginPath,
|
|
"Plugin entry method returned an object that does not implement IHistorianDataSource");
|
|
return null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning(ex, "Failed to load historian plugin from {PluginPath} — history disabled", pluginPath);
|
|
LastOutcome = new HistorianPluginOutcome(
|
|
HistorianPluginStatus.LoadFailed, pluginPath,
|
|
ex.GetBaseException().Message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static void EnsureAssemblyResolverInstalled(string pluginDirectory)
|
|
{
|
|
lock (ResolverGate)
|
|
{
|
|
_resolvedProbeDirectory = pluginDirectory;
|
|
if (_resolverInstalled)
|
|
return;
|
|
|
|
AppDomain.CurrentDomain.AssemblyResolve += ResolveFromPluginDirectory;
|
|
_resolverInstalled = true;
|
|
}
|
|
}
|
|
|
|
private static Assembly? ResolveFromPluginDirectory(object? sender, ResolveEventArgs args)
|
|
{
|
|
var probeDirectory = _resolvedProbeDirectory;
|
|
if (string.IsNullOrEmpty(probeDirectory))
|
|
return null;
|
|
|
|
var requested = new AssemblyName(args.Name);
|
|
var candidate = Path.Combine(probeDirectory!, requested.Name + ".dll");
|
|
if (!File.Exists(candidate))
|
|
return null;
|
|
|
|
try
|
|
{
|
|
return Assembly.LoadFrom(candidate);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Debug(ex, "Historian plugin resolver failed to load {Candidate}", candidate);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
}
|