diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmFallbackOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmFallbackOptions.cs
new file mode 100644
index 0000000..8760764
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmFallbackOptions.cs
@@ -0,0 +1,125 @@
+namespace ZB.MOM.WW.MxGateway.Server.Configuration;
+
+///
+/// Controls how the central alarm monitor selects between the MXAccess
+/// alarm-manager subscription and the subtag-polling fallback, and
+/// governs the failure-detection thresholds used when switching.
+///
+public sealed class AlarmFallbackOptions
+{
+ ///
+ /// Selects the operating mode for the alarm-manager ↔ subtag fallback
+ /// mechanism. Accepted values (case-insensitive):
+ ///
+ /// - Auto — use the alarm manager; switch to subtag polling
+ /// automatically when failures
+ /// are detected, and probe for failback.
+ /// - ForceAlarmManager — always use the alarm manager;
+ /// never fall back.
+ /// - ForceSubtag — always use subtag polling;
+ /// never try the alarm manager.
+ ///
+ /// Default is Auto.
+ ///
+ public string Mode { get; init; } = "Auto";
+
+ ///
+ /// Number of consecutive alarm-manager failures before the monitor
+ /// switches to subtag-polling fallback. Must be at least 1. Default 3.
+ ///
+ public int ConsecutiveFailureThreshold { get; init; } = 3;
+
+ ///
+ /// How often (in seconds) the monitor sends a probe to the alarm manager
+ /// while operating in subtag-polling fallback mode, to detect recovery.
+ /// Must be at least 1. Default 30.
+ ///
+ public int FailbackProbeIntervalSeconds { get; init; } = 30;
+
+ ///
+ /// Number of consecutive successful probes required before the monitor
+ /// considers the alarm manager recovered and switches back. Must be at
+ /// least 1. Default 3.
+ ///
+ public int FailbackStableProbes { get; init; } = 3;
+
+ ///
+ /// Controls how the monitor discovers the set of objects to poll when
+ /// operating in subtag-polling fallback mode.
+ ///
+ public AlarmDiscoveryOptions Discovery { get; init; } = new();
+
+ ///
+ /// Configures the subtag names the monitor reads when polling alarm state
+ /// in subtag-fallback mode.
+ ///
+ public AlarmSubtagNameOptions Subtags { get; init; } = new();
+}
+
+///
+/// Governs how the alarm monitor discovers objects to include in subtag-polling
+/// fallback mode. Either the Galaxy Repository query (when
+/// is true) or an explicit
+/// list must be supplied when
+/// MxGateway:Alarms:Fallback:Mode is ForceSubtag.
+///
+public sealed class AlarmDiscoveryOptions
+{
+ ///
+ /// When true the monitor queries the Galaxy Repository SQL database
+ /// to enumerate alarm objects for the configured area. Default true.
+ ///
+ public bool UseGalaxyRepository { get; init; } = true;
+
+ ///
+ /// Galaxy area to scope the Repository query to. When empty the monitor
+ /// falls back to . Ignored when
+ /// is false.
+ ///
+ public string Area { get; init; } = string.Empty;
+
+ ///
+ /// Explicit list of MXAccess attribute paths to include in subtag polling,
+ /// supplementing (or replacing, when is
+ /// false) the Repository-derived list. Default empty.
+ ///
+ public string[] IncludeAttributes { get; init; } = Array.Empty();
+
+ ///
+ /// Attribute paths to exclude from the Repository-derived poll list.
+ /// Ignored when is false.
+ /// Default empty.
+ ///
+ public string[] ExcludeAttributes { get; init; } = Array.Empty();
+}
+
+///
+/// Configures the subtag names read by the alarm monitor when it is operating
+/// in subtag-polling fallback mode. Names are matched against MXAccess item
+/// handles; validation against the live MXAccess attribute list occurs at
+/// runtime, not at startup.
+///
+public sealed class AlarmSubtagNameOptions
+{
+ ///
+ /// Subtag name for the active-alarm flag. Default active.
+ ///
+ public string Active { get; init; } = "active";
+
+ ///
+ /// Subtag name for the acknowledged flag. Default acked.
+ ///
+ public string Acked { get; init; } = "acked";
+
+ ///
+ /// Optional subtag name for the acknowledgement comment field.
+ /// When empty the feature is disabled. Verified against MXAccess at
+ /// runtime before use. Default empty.
+ ///
+ public string AckComment { get; init; } = string.Empty;
+
+ ///
+ /// Subtag name for the alarm priority. Default priority.
+ ///
+ public string Priority { get; init; } = "priority";
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs
index 38563fa..565eac8 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs
@@ -45,4 +45,12 @@ public sealed class AlarmsOptions
/// the monitor floors it at 5 seconds.
///
public int ReconcileIntervalSeconds { get; init; } = 30;
+
+ ///
+ /// Configuration for the alarm-manager ↔ subtag fallback mechanism:
+ /// operating mode, failure-detection thresholds, discovery, and subtag
+ /// names. Defaults (Mode = "Auto") preserve behaviour when the section is
+ /// omitted from configuration.
+ ///
+ public AlarmFallbackOptions Fallback { get; init; } = new();
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs
index c228a5c..fb7ed3e 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs
@@ -231,6 +231,8 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase\Galaxy! shape).");
}
+
+ ValidateAlarmFallback(options.Fallback, builder);
+ }
+
+ private static void ValidateAlarmFallback(AlarmFallbackOptions fallback, ValidationBuilder builder)
+ {
+ // Validate Mode is one of the recognised values (case-insensitive).
+ bool modeValid = Array.Exists(
+ ValidAlarmFallbackModes,
+ m => string.Equals(m, fallback.Mode, StringComparison.OrdinalIgnoreCase));
+
+ if (!modeValid)
+ {
+ builder.Add(
+ $"MxGateway:Alarms:Fallback:Mode must be one of: {string.Join(", ", ValidAlarmFallbackModes)} (was '{fallback.Mode}').");
+ }
+
+ // ForceSubtag requires either Galaxy Repository discovery or an explicit IncludeAttributes list.
+ if (modeValid
+ && string.Equals(fallback.Mode, "ForceSubtag", StringComparison.OrdinalIgnoreCase)
+ && !fallback.Discovery.UseGalaxyRepository
+ && fallback.Discovery.IncludeAttributes.Length == 0)
+ {
+ builder.Add(
+ "MxGateway:Alarms:Fallback ForceSubtag requires Galaxy Repository discovery or a non-empty Discovery:IncludeAttributes list.");
+ }
+
+ // Floor validation: numeric thresholds must be at least 1.
+ AddIfNotPositive(
+ fallback.ConsecutiveFailureThreshold,
+ "MxGateway:Alarms:Fallback:ConsecutiveFailureThreshold must be greater than zero.",
+ builder);
+ AddIfNotPositive(
+ fallback.FailbackProbeIntervalSeconds,
+ "MxGateway:Alarms:Fallback:FailbackProbeIntervalSeconds must be greater than zero.",
+ builder);
+ AddIfNotPositive(
+ fallback.FailbackStableProbes,
+ "MxGateway:Alarms:Fallback:FailbackStableProbes must be greater than zero.",
+ builder);
}
private const int MinimumCertValidityYears = 1;
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs
index 49d8165..eea5285 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs
@@ -118,4 +118,181 @@ public sealed class GatewayOptionsValidatorTests
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
+
+ // -------------------------------------------------------------------------
+ // AlarmFallbackOptions validation
+ // -------------------------------------------------------------------------
+
+ private static AlarmsOptions EnabledAlarmsWithFallback(AlarmFallbackOptions fallback) => new()
+ {
+ Enabled = true,
+ DefaultArea = "Galaxy",
+ Fallback = fallback,
+ };
+
+ private static GatewayOptions CloneWithAlarms(GatewayOptions source, AlarmsOptions alarms)
+ => new()
+ {
+ Authentication = source.Authentication,
+ Ldap = source.Ldap,
+ Worker = source.Worker,
+ Sessions = source.Sessions,
+ Events = source.Events,
+ Dashboard = source.Dashboard,
+ Protocol = source.Protocol,
+ Alarms = alarms,
+ Tls = source.Tls,
+ };
+
+ [Fact]
+ public void Validate_Succeeds_WhenAlarmsDisabled_FallbackNotValidated()
+ {
+ // Even an invalid Mode is acceptable when Enabled = false.
+ GatewayOptions options = CloneWithAlarms(
+ ValidOptions(),
+ new AlarmsOptions
+ {
+ Enabled = false,
+ Fallback = new AlarmFallbackOptions { Mode = "InvalidMode" },
+ });
+ ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
+ Assert.True(result.Succeeded);
+ }
+
+ [Fact]
+ public void Validate_Succeeds_WhenAlarmsEnabled_DefaultAutoConfig()
+ {
+ // Default AlarmFallbackOptions (Mode="Auto") must pass validation when alarms are enabled.
+ GatewayOptions options = CloneWithAlarms(
+ ValidOptions(),
+ EnabledAlarmsWithFallback(new AlarmFallbackOptions()));
+ ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
+ Assert.True(result.Succeeded);
+ }
+
+ [Theory]
+ [InlineData("Auto")]
+ [InlineData("ForceAlarmManager")]
+ [InlineData("ForceSubtag")]
+ [InlineData("auto")]
+ [InlineData("FORCESUBTAG")]
+ public void Validate_Succeeds_WhenAlarmsEnabled_RecognisedMode(string mode)
+ {
+ AlarmsOptions alarms = mode.Equals("ForceSubtag", StringComparison.OrdinalIgnoreCase)
+ // ForceSubtag needs either UseGalaxyRepository=true (default) or IncludeAttributes.
+ ? EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = mode })
+ : EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = mode });
+ GatewayOptions options = CloneWithAlarms(ValidOptions(), alarms);
+ ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
+ Assert.True(result.Succeeded);
+ }
+
+ [Fact]
+ public void Validate_Fails_WhenAlarmsEnabled_InvalidMode()
+ {
+ GatewayOptions options = CloneWithAlarms(
+ ValidOptions(),
+ EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = "InvalidMode" }));
+ ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
+ Assert.True(result.Failed);
+ Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Alarms:Fallback") && f.Contains("Mode"));
+ }
+
+ [Fact]
+ public void Validate_Fails_WhenForceSubtag_NoGalaxyRepository_NoIncludes()
+ {
+ // ForceSubtag without galaxy repository and without IncludeAttributes must fail.
+ GatewayOptions options = CloneWithAlarms(
+ ValidOptions(),
+ EnabledAlarmsWithFallback(new AlarmFallbackOptions
+ {
+ Mode = "ForceSubtag",
+ Discovery = new AlarmDiscoveryOptions
+ {
+ UseGalaxyRepository = false,
+ IncludeAttributes = [],
+ },
+ }));
+ ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
+ Assert.True(result.Failed);
+ Assert.Contains(
+ result.Failures!,
+ f => f.Contains("ForceSubtag") && f.Contains("Discovery"));
+ }
+
+ [Fact]
+ public void Validate_Succeeds_WhenForceSubtag_NoGalaxyRepository_WithIncludes()
+ {
+ // ForceSubtag without galaxy repository is allowed when IncludeAttributes is non-empty.
+ GatewayOptions options = CloneWithAlarms(
+ ValidOptions(),
+ EnabledAlarmsWithFallback(new AlarmFallbackOptions
+ {
+ Mode = "ForceSubtag",
+ Discovery = new AlarmDiscoveryOptions
+ {
+ UseGalaxyRepository = false,
+ IncludeAttributes = ["attr1"],
+ },
+ }));
+ ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
+ Assert.True(result.Succeeded);
+ }
+
+ [Fact]
+ public void Validate_Succeeds_WhenForceSubtag_WithGalaxyRepository()
+ {
+ // ForceSubtag + UseGalaxyRepository=true (default) must pass even without IncludeAttributes.
+ GatewayOptions options = CloneWithAlarms(
+ ValidOptions(),
+ EnabledAlarmsWithFallback(new AlarmFallbackOptions
+ {
+ Mode = "ForceSubtag",
+ Discovery = new AlarmDiscoveryOptions { UseGalaxyRepository = true },
+ }));
+ ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
+ Assert.True(result.Succeeded);
+ }
+
+ [Theory]
+ [InlineData(0, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))]
+ [InlineData(-1, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))]
+ public void Validate_Fails_WhenConsecutiveFailureThresholdBelowOne(int value, string keyPart)
+ {
+ GatewayOptions options = CloneWithAlarms(
+ ValidOptions(),
+ EnabledAlarmsWithFallback(new AlarmFallbackOptions { ConsecutiveFailureThreshold = value }));
+ ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
+ Assert.True(result.Failed);
+ Assert.Contains(result.Failures!, f => f.Contains(keyPart));
+ _ = keyPart; // suppress unused-param warning
+ }
+
+ [Theory]
+ [InlineData(0, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))]
+ [InlineData(-5, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))]
+ public void Validate_Fails_WhenFailbackProbeIntervalSecondsBelowOne(int value, string keyPart)
+ {
+ GatewayOptions options = CloneWithAlarms(
+ ValidOptions(),
+ EnabledAlarmsWithFallback(new AlarmFallbackOptions { FailbackProbeIntervalSeconds = value }));
+ ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
+ Assert.True(result.Failed);
+ Assert.Contains(result.Failures!, f => f.Contains(keyPart));
+ _ = keyPart;
+ }
+
+ [Theory]
+ [InlineData(0, nameof(AlarmFallbackOptions.FailbackStableProbes))]
+ [InlineData(-1, nameof(AlarmFallbackOptions.FailbackStableProbes))]
+ public void Validate_Fails_WhenFailbackStableProbesBelowOne(int value, string keyPart)
+ {
+ GatewayOptions options = CloneWithAlarms(
+ ValidOptions(),
+ EnabledAlarmsWithFallback(new AlarmFallbackOptions { FailbackStableProbes = value }));
+ ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
+ Assert.True(result.Failed);
+ Assert.Contains(result.Failures!, f => f.Contains(keyPart));
+ _ = keyPart;
+ }
}