From ffacbe03709aaf886f8b10bda57839d2183f0c49 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 30 Apr 2026 16:20:11 -0400 Subject: [PATCH] sidecar: wire IAlarmEventWriter into Program.cs (PR C.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth PR of the alarms-over-gateway epic (docs/plans/alarms-over-gateway.md). Depends on PR C.1 (AahClientManagedAlarmEventWriter), already merged. Today HistorianFrameHandler is constructed at Program.cs line 57 without an alarmWriter, so every WriteAlarmEvents frame replies "Sidecar not configured with an alarm-event writer" and the lmxopcua side keeps the row queued. C.2 wires a real writer behind a new OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED toggle. - Program.BuildAlarmWriter — gated on the env var (default true, fail-open under accidental misconfiguration). Constructs an AahClientManagedAlarmEventWriter wrapping a SdkAlarmHistorianWriteBackend with the same connection config the read path uses. - Install-Services.ps1 — appends OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=true to the OtOpcUaWonderwareHistorian service env block when the sidecar is installed. Read-only deployments flip it to false at service-config edit time without re-installing. - HistorianFrameHandler signature already accepts IAlarmEventWriter? — supplying non-null at line 57 lights up the WriteAlarmEvents reply path that's been dormant since PR 3.3. Until PR D.1 pins the live aahClientManaged entry point, the SdkAlarmHistorianWriteBackend reports RetryPlease for every event with a structured diagnostic. The lmxopcua-side SqliteStoreAndForwardSink retains queued events; same effective behaviour as today's NullAlarmHistorianSink fallback but with visible diagnostics rather than silent discard. Tests: - 6 BuildAlarmWriter env-var cases — unset / true / false / unrecognized → default-on / capitalization variants. - Full sidecar test suite: 56 passed (was 48; 8 new). Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/install/Install-Services.ps1 | 4 + .../Program.cs | 45 +++++++++- .../ProgramAlarmWriterTests.cs | 82 +++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ProgramAlarmWriterTests.cs diff --git a/scripts/install/Install-Services.ps1 b/scripts/install/Install-Services.ps1 index d411ba0..15a5b0a 100644 --- a/scripts/install/Install-Services.ps1 +++ b/scripts/install/Install-Services.ps1 @@ -87,6 +87,10 @@ if ($InstallWonderwareHistorian) { "OTOPCUA_ALLOWED_SID=$sid" "OTOPCUA_HISTORIAN_SECRET=$HistorianSharedSecret" "OTOPCUA_HISTORIAN_ENABLED=true" + # Default-on when the historian sidecar is installed; flip to false for a + # read-only deployment that still loads aahClientManaged for reads but + # rejects WriteAlarmEvents frames. + "OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=true" "OTOPCUA_HISTORIAN_SERVER=$HistorianServer" "OTOPCUA_HISTORIAN_PORT=$HistorianPort" ) -join "`0" diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Program.cs index a347081..6db08b9 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Program.cs @@ -54,7 +54,8 @@ public static class Program } using var historian = BuildHistorian(); - var handler = new HistorianFrameHandler(historian, Log.Logger); + var alarmWriter = BuildAlarmWriter(); + var handler = new HistorianFrameHandler(historian, Log.Logger, alarmWriter); using var server = new PipeServer(pipeName, allowedSid, sharedSecret, Log.Logger); Log.Information("Wonderware historian sidecar serving — pipe={Pipe} allowedSid={Sid}", pipeName, allowedSidValue); @@ -107,4 +108,46 @@ public static class Program var raw = Environment.GetEnvironmentVariable(envName); return int.TryParse(raw, out var parsed) ? parsed : defaultValue; } + + /// + /// 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), + }; + } } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ProgramAlarmWriterTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ProgramAlarmWriterTests.cs new file mode 100644 index 0000000..ce2f835 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ProgramAlarmWriterTests.cs @@ -0,0 +1,82 @@ +using System; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests +{ + /// + /// PR C.2 — pins the env-var contract that gates whether the sidecar boots an + /// alarm-event writer. Default-on (when the historian itself is enabled) so a + /// fresh deploy picks up the writer without a service-config edit; explicit + /// false opts a read-only deployment out. + /// + [Trait("Category", "Unit")] + public sealed class ProgramAlarmWriterTests + { + [Fact] + public void BuildAlarmWriter_returns_writer_when_env_unset() + { + using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", null); + + var writer = Program.BuildAlarmWriter(); + + writer.ShouldNotBeNull(); + writer.ShouldBeOfType(); + } + + [Theory] + [InlineData("true")] + [InlineData("True")] + [InlineData("TRUE")] + public void BuildAlarmWriter_returns_writer_when_env_explicitly_true(string value) + { + using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", value); + + var writer = Program.BuildAlarmWriter(); + + writer.ShouldNotBeNull(); + } + + [Theory] + [InlineData("false")] + [InlineData("False")] + [InlineData("FALSE")] + public void BuildAlarmWriter_returns_null_when_env_false(string value) + { + using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", value); + + var writer = Program.BuildAlarmWriter(); + + writer.ShouldBeNull(); + } + + [Fact] + public void BuildAlarmWriter_treats_unrecognized_value_as_enabled() + { + // Anything other than the literal "false" (case-insensitive) keeps the writer + // wired — fail-open under accidental misconfiguration so an alarm-write deploy + // doesn't silently lose alarms because of a typo. + using var _ = ScopedEnv("OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED", "yes"); + + var writer = Program.BuildAlarmWriter(); + + writer.ShouldNotBeNull(); + } + + private static IDisposable ScopedEnv(string name, string? value) + { + var prior = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + return new DisposableAction(() => Environment.SetEnvironmentVariable(name, prior)); + } + + private sealed class DisposableAction : IDisposable + { + private readonly Action _action; + public DisposableAction(Action action) { _action = action; } + public void Dispose() => _action(); + } + } +}