using System;
using System.Net;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware;
///
/// Entry point for the Wonderware Historian sidecar. Reads the shared secret, TCP
/// bind/port, optional TLS settings, and historian connection config from environment
/// (the supervisor passes them at spawn time per driver-stability.md). Hosts a
/// TCP server (optionally over TLS) dispatching the five sidecar contracts (PR 3.3) to
/// the Wonderware Historian SDK.
///
public static class Program
{
/// Entry point for the Wonderware Historian sidecar process.
/// Command-line arguments (unused).
/// 0 on success, 2 on fatal error.
public static int Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.File(
@"%ProgramData%\OtOpcUa\historian-wonderware-.log".Replace("%ProgramData%", Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)),
rollingInterval: RollingInterval.Day)
.CreateLogger();
try
{
var sharedSecret = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SECRET")
?? throw new InvalidOperationException("OTOPCUA_HISTORIAN_SECRET not set — supervisor must pass the per-process secret at spawn time");
var tcpPort = TryParseInt("OTOPCUA_HISTORIAN_TCP_PORT", 32569);
var bindRaw = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_BIND");
var bind = string.IsNullOrWhiteSpace(bindRaw) ? IPAddress.Any : IPAddress.Parse(bindRaw);
var tlsEnabled = string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_TLS_ENABLED"), "true", StringComparison.OrdinalIgnoreCase);
X509Certificate2? tlsCert = tlsEnabled ? LoadTlsCert() : null;
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
// Sidecar can boot in "tcp idle" mode (no real Wonderware Historian SDK
// initialization) for smoke + IPC tests. Production sets ENABLED=true so the
// SDK opens its connection up front.
var historianEnabled = string.Equals(
Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_ENABLED"),
"true", StringComparison.OrdinalIgnoreCase);
if (!historianEnabled)
{
Log.Information("Wonderware historian sidecar starting in tcp idle mode (SDK disabled) (OTOPCUA_HISTORIAN_ENABLED!=true) — bind={Bind} port={Port} tls={Tls}", bind, tcpPort, tlsCert is not null);
cts.Token.WaitHandle.WaitOne();
Log.Information("Wonderware historian sidecar stopping cleanly");
return 0;
}
using var historian = BuildHistorian();
var alarmWriter = BuildAlarmWriter();
var handler = new HistorianFrameHandler(historian, Log.Logger, alarmWriter);
using var server = new TcpFrameServer(bind, tcpPort, sharedSecret, tlsCert, Log.Logger);
Log.Information("Wonderware historian sidecar serving — bind={Bind} port={Port} tls={Tls}", bind, tcpPort, tlsCert is not null);
try { server.RunAsync(handler, cts.Token).GetAwaiter().GetResult(); }
catch (OperationCanceledException) { /* clean shutdown via Ctrl-C */ }
Log.Information("Wonderware historian sidecar stopped cleanly");
return 0;
}
catch (Exception ex)
{
Log.Fatal(ex, "Wonderware historian sidecar fatal");
return 2;
}
finally { Log.CloseAndFlush(); }
}
///
/// Builds the Wonderware Historian data source from environment variables. Mirrors
/// the env-var contract that Driver.Galaxy.Host used in v1; PR 3.W reaffirms
/// this contract in install scripts.
///
private static HistorianDataSource BuildHistorian()
{
var cfg = new HistorianConfiguration
{
Enabled = true,
ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
IntegratedSecurity = !string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_INTEGRATED"), "false", StringComparison.OrdinalIgnoreCase),
UserName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_USER"),
Password = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PASS"),
CommandTimeoutSeconds = TryParseInt("OTOPCUA_HISTORIAN_TIMEOUT_SEC", 30),
MaxValuesPerRead = TryParseInt("OTOPCUA_HISTORIAN_MAX_VALUES", 10000),
FailureCooldownSeconds = TryParseInt("OTOPCUA_HISTORIAN_COOLDOWN_SEC", 60),
};
var servers = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVERS");
if (!string.IsNullOrWhiteSpace(servers))
cfg.ServerNames = new System.Collections.Generic.List(
servers.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries));
Log.Information("Sidecar Historian config — {NodeCount} node(s), port={Port}",
cfg.ServerNames.Count > 0 ? cfg.ServerNames.Count : 1, cfg.Port);
return new HistorianDataSource(cfg);
}
private static int TryParseInt(string envName, int defaultValue)
{
var raw = Environment.GetEnvironmentVariable(envName);
return int.TryParse(raw, out var parsed) ? parsed : defaultValue;
}
///
/// Loads the TLS server certificate when TLS is enabled. The reference is either a
/// .pfx file path (decrypted with the optional password env var) or, if not a
/// file, a thumbprint resolved from the LocalMachine\My store.
///
private static X509Certificate2 LoadTlsCert()
{
var certRef = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_TLS_CERT")
?? throw new InvalidOperationException("OTOPCUA_HISTORIAN_TLS_CERT not set but TLS enabled — supply a .pfx path or a LocalMachine\\My store thumbprint");
var pwd = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_TLS_CERT_PASSWORD");
if (System.IO.File.Exists(certRef))
return new X509Certificate2(certRef, pwd, X509KeyStorageFlags.MachineKeySet);
// else treat as a thumbprint in LocalMachine\My
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly);
var found = store.Certificates.Find(X509FindType.FindByThumbprint, certRef.Replace(" ", ""), validOnly: false);
if (found.Count == 0) throw new InvalidOperationException($"OTOPCUA_HISTORIAN_TLS_CERT thumbprint '{certRef}' not found in LocalMachine\\My and is not a file path");
return found[0];
}
///
/// Constructs the alarm-event writer when the alarm-write toggle is on, otherwise
/// returns null so falls back to the
/// "not configured" reply for any incoming WriteAlarmEvents frame.
/// Default is true when OTOPCUA_HISTORIAN_ENABLED=true; explicitly
/// set OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=false to keep a read-only
/// deployment that still loads the SDK for reads.
///
internal static IAlarmEventWriter? BuildAlarmWriter()
{
var raw = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED");
var enabled = string.IsNullOrWhiteSpace(raw)
? true
: !string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase);
if (!enabled)
{
Log.Information("Alarm-event writer disabled (OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=false); historian sidecar will reject WriteAlarmEvents frames.");
return null;
}
var cfg = BuildAlarmWriterConfig();
var backend = new SdkAlarmHistorianWriteBackend(cfg);
Log.Information("Alarm-event writer enabled — backend=SdkAlarmHistorianWriteBackend server={Server}", cfg.ServerName);
return new AahClientManagedAlarmEventWriter(backend);
}
private static HistorianConfiguration BuildAlarmWriterConfig()
{
return new HistorianConfiguration
{
Enabled = true,
ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
IntegratedSecurity = !string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_INTEGRATED"), "false", StringComparison.OrdinalIgnoreCase),
UserName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_USER"),
Password = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PASS"),
CommandTimeoutSeconds = TryParseInt("OTOPCUA_HISTORIAN_TIMEOUT_SEC", 30),
FailureCooldownSeconds = TryParseInt("OTOPCUA_HISTORIAN_COOLDOWN_SEC", 60),
};
}
}