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(); + } + } +}