Sidecar now serves a length-prefixed, kind-tagged MessagePack pipe protocol mirroring Galaxy.Host's: 4-byte BE length + 1-byte MessageKind + body, 16 MiB cap. Hello handshake validates per-process shared secret + protocol major version + caller SID via ImpersonateNamedPipeClient before any work frame runs. Five contract pairs ship in this PR: ReadRawRequest ↔ ReadRawReply ReadProcessedRequest ↔ ReadProcessedReply ReadAtTimeRequest ↔ ReadAtTimeReply ReadEventsRequest ↔ ReadEventsReply WriteAlarmEventsRequest ↔ WriteAlarmEventsReply Timestamps cross the wire as DateTime ticks (long) to dodge MessagePack's DateTime kind/timezone quirks; both sides convert with DateTime(ticks, Utc). Sample values cross as MessagePack-serialized byte[] so the .NET 10 client (PR 3.4) deserializes per the tag's mx_data_type without the sidecar needing to know OPC UA types. HistorianFrameHandler dispatches by MessageKind to IHistorianDataSource (the PR 3.2 lifted interface) for reads, and to a new IAlarmEventWriter strategy for the alarm-event persistence path. Per-call exceptions surface as Success=false replies so a single bad request doesn't kill the connection. WriteAlarmEvents replies carry per-event success flags; the SQLite store-and-forward sink retries failed slots on the next drain tick. Program.cs spins the pipe server when OTOPCUA_HISTORIAN_ENABLED=true. Pipe- only mode (default false) preserves PR 3.1's smoke-test behaviour: the host still validates env vars and waits for Ctrl-C, but doesn't initialize the Wonderware SDK. Sidecar test project gains 8 round-trip tests (37 total now): every contract pair round-trips through FrameReader/FrameWriter via in-memory streams, the handler surfaces historian exceptions cleanly, WriteAlarmEvents per-event status flows through, and the no-writer-configured path returns a clean error reply. Added MessagePack 2.5.187 to the sidecar csproj. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
111 lines
5.5 KiB
C#
111 lines
5.5 KiB
C#
using System;
|
|
using System.Security.Principal;
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Entry point for the Wonderware Historian sidecar. Reads pipe name, allowed-SID,
|
|
/// shared secret, and historian connection config from environment (the supervisor
|
|
/// passes them at spawn time per <c>driver-stability.md</c>). Hosts a named-pipe server
|
|
/// dispatching the five sidecar contracts (PR 3.3) to the Wonderware Historian SDK.
|
|
/// </summary>
|
|
public static class Program
|
|
{
|
|
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 pipeName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PIPE")
|
|
?? throw new InvalidOperationException("OTOPCUA_HISTORIAN_PIPE not set — supervisor must pass the sidecar pipe name");
|
|
var allowedSidValue = Environment.GetEnvironmentVariable("OTOPCUA_ALLOWED_SID")
|
|
?? throw new InvalidOperationException("OTOPCUA_ALLOWED_SID not set — supervisor must pass the server principal SID");
|
|
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 allowedSid = new SecurityIdentifier(allowedSidValue);
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
|
|
|
|
// Sidecar can boot in "pipe-only" 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 pipe-only mode (OTOPCUA_HISTORIAN_ENABLED!=true) — pipe={Pipe} allowedSid={Sid}", pipeName, allowedSidValue);
|
|
cts.Token.WaitHandle.WaitOne();
|
|
Log.Information("Wonderware historian sidecar stopping cleanly");
|
|
return 0;
|
|
}
|
|
|
|
using var historian = BuildHistorian();
|
|
var handler = new HistorianFrameHandler(historian, Log.Logger);
|
|
using var server = new PipeServer(pipeName, allowedSid, sharedSecret, Log.Logger);
|
|
|
|
Log.Information("Wonderware historian sidecar serving — pipe={Pipe} allowedSid={Sid}", pipeName, allowedSidValue);
|
|
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(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the Wonderware Historian data source from environment variables. Mirrors
|
|
/// the env-var contract that <c>Driver.Galaxy.Host</c> used in v1; PR 3.W reaffirms
|
|
/// this contract in install scripts.
|
|
/// </summary>
|
|
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<string>(
|
|
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;
|
|
}
|
|
}
|