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