diff --git a/docker/seed-mes-alarm-demo.sh b/docker/seed-mes-alarm-demo.sh index 42023453..fc864585 100755 --- a/docker/seed-mes-alarm-demo.sh +++ b/docker/seed-mes-alarm-demo.sh @@ -28,7 +28,9 @@ set -euo pipefail # Only the API key cannot be re-created (key display names are not unique and the token is not # retrievable) — a re-run says so instead of minting a second key. # -# Prerequisites: bash docker/deploy.sh; docker/seed-sites.sh. +# Prerequisites: bash docker/deploy.sh; docker/seed-sites.sh; build the CLI first: +# dotnet build src/ZB.MOM.WW.ScadaBridge.CLI +# (this script invokes the Debug binary by path — see $CLI below). # # Live gate: MES_ALARM_DEMO_KEY= bash docker/verify-mes-alarm-demo.sh diff --git a/docs/requirements/Component-CLI.md b/docs/requirements/Component-CLI.md index 87b7492e..4e0dbd4c 100644 --- a/docs/requirements/Component-CLI.md +++ b/docs/requirements/Component-CLI.md @@ -110,6 +110,12 @@ scadabridge instance delete --id `[["Speed", 5], ["Mode", 7]]`. `--overrides` is a JSON object of attribute name to value, e.g. `{"Speed": "100", "Mode": null}`. +`instance alarm-override set` replaces the **whole** override row — an omitted flag is not +"leave as is", it clears that field back to inherited. `instance alarm-override set +--instance-id 6 --alarm X --mes-reportable true` wipes any existing `--priority` / +`--trigger-config` override on alarm `X`; pass every override to keep on the same command +(`instance alarm-override list` shows the current values). + ### Native Alarm Source Commands The `native-alarm-source` subcommands manage the **read-only native alarm mirror** — diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index 117c9959..1cd9f0cc 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -150,7 +150,7 @@ Central cluster only. Sites have no user interface. - **Type column** — Variable rows display best-effort type info (data type friendly name, scalar/array value rank, writable flag) read from the server during browse. - Set instance-level attribute overrides (non-locked attributes only). - **Bulk override CSV import** (`InstanceConfigure`): a Blazor `InputFile` upload accepts a CSV of `AttributeName, Value, ElementType?` rows (`ElementType` only for `List` attributes). Each row is validated against the instance's flattened attribute schema (name exists + value type-compatible, reusing the existing override validation); the import is **all-or-nothing** — any per-row error aborts the whole upload with a per-row error summary and nothing is applied. On success the rows are upserted through the same ManagementActor add/update-override handlers used by the inline editor. The same import is available from the CLI (`instance import-overrides --file`, see Component-CLI.md). -- **Alarm Overrides card** (`InstanceConfigure`) gained an **"MES reportable"** tri-state select (Inherit / Yes / No, 2026-09-11) per alarm row, bound to `InstanceAlarmOverride.IsMesReportableOverride` — Inherit maps to `null` (use the template value); the row is disabled for locked alarms, same as the existing Priority/Trigger override fields. `instance alarm-override set --mes-reportable true|false` (omit the flag to inherit) is the CLI equivalent. +- **Alarm Overrides card** (`InstanceConfigure`) gained an **"MES reportable override"** tri-state select (2026-09-11) in the per-alarm **override modal** (not on the list row), bound to `InstanceAlarmOverride.IsMesReportableOverride` — the options are "Inherited (reportable | not reportable)" (the parenthetical reflects the template alarm's own flag) → `null`, "Reportable" → `true`, and "Not reportable" → `false`, which is a real override pinning the alarm off for this instance rather than a clear. The list row shows the inherited flag as a green **MES** badge and any override in its override summary. Locked alarms are **filtered out of the override list entirely** (the card only lists non-locked alarms), so there is no disabled-row state for them. `instance alarm-override set --mes-reportable true|false` (omit the flag to inherit) is the CLI equivalent. - **Native Alarm Source Overrides card** (`InstanceConfigure`): a card placed **after the Alarm Overrides card**, listing the template's native alarm sources for per-instance binding. Each row offers **inline override** of the three fields that typically vary per physical instance: - **Connection** — a dropdown (same alarm-capable filtering as the template editor). - **Source Reference** — the concrete native key for this instance. diff --git a/docs/requirements/Component-TemplateEngine.md b/docs/requirements/Component-TemplateEngine.md index 34e9c812..d9d95c04 100644 --- a/docs/requirements/Component-TemplateEngine.md +++ b/docs/requirements/Component-TemplateEngine.md @@ -110,7 +110,7 @@ breadcrumb. Override and lock rules apply per entity type at the following granularity: - **Attributes**: Value and Description are overridable. Data Type is fixed by the defining level. `DataSourceReference` on a template attribute defines the **default** physical address for that attribute. Instances may override per attribute via `InstanceConnectionBinding.DataSourceReferenceOverride`; the override replaces the template default at flattening time. When the override is null (the default), the template value is used. Lock applies to the entire attribute (when locked, no fields can be overridden). -- **Alarms**: Priority Level and Trigger Definition (thresholds/ranges/rates) are instance-overridable. Description and On-Trigger Script reference are **not** instance-overridable — `InstanceAlarmOverride` carries only `TriggerConfigurationOverride` and `PriorityLevelOverride`, so an instance cannot re-point an alarm's on-trigger script or reword its description (those are template-level authoring decisions). Name and Trigger Type (Value Match vs. Range vs. Rate of Change) are fixed. Lock applies to the entire alarm. `TriggerConfigurationOverride` is a **partial merge for `HiLo`** triggers — setpoints left unset in the override keep their inherited values — and a **whole-config replacement for every other trigger type**. (Recorded decision, arch-review 05: this narrower granularity is the implemented behavior; adding Description/OnTriggerScript override columns is future feature work, not spec debt.) `TemplateAlarm.IsMesReportable` (bool, default false, "MES reportable" checkbox — `template alarm add|update --mes-reportable`) is instance-overridable via the nullable `InstanceAlarmOverride.IsMesReportableOverride` (null = inherit the template value; `instance alarm-override set --mes-reportable true|false`, omitted = inherit). A locked alarm cannot take this override either, same as every other alarm field. Migration `20260911135501_AddAlarmMesReportable` (Config DB, 2026-09-11); flattening resolves `ResolvedAlarm.IsMesReportable = override ?? template` and ships it in the deployed config JSON — no site schema change. See `docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md`. +- **Alarms**: Priority Level, Trigger Definition (thresholds/ranges/rates) and the MES-reportable flag are instance-overridable — `InstanceAlarmOverride` carries exactly three override fields: `TriggerConfigurationOverride`, `PriorityLevelOverride` and `IsMesReportableOverride`. Description and On-Trigger Script reference are **not** instance-overridable (there is no override column for either), so an instance cannot re-point an alarm's on-trigger script or reword its description — those are template-level authoring decisions. Name and Trigger Type (Value Match vs. Range vs. Rate of Change) are fixed. Lock applies to the entire alarm. `TriggerConfigurationOverride` is a **partial merge for `HiLo`** triggers — setpoints left unset in the override keep their inherited values — and a **whole-config replacement for every other trigger type**. (Recorded decision, arch-review 05: this narrower granularity is the implemented behavior; adding Description/OnTriggerScript override columns is future feature work, not spec debt.) `TemplateAlarm.IsMesReportable` (bool, default false, "MES reportable" checkbox — `template alarm add|update --mes-reportable`) is instance-overridable via the nullable `InstanceAlarmOverride.IsMesReportableOverride` (null = inherit the template value; `instance alarm-override set --mes-reportable true|false`, omitted = inherit). A locked alarm cannot take this override either, same as every other alarm field. Migration `20260911135501_AddAlarmMesReportable` (Config DB, 2026-09-11); flattening resolves `ResolvedAlarm.IsMesReportable = override ?? template` and ships it in the deployed config JSON — no site schema change. See `docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md`. - **Native alarm sources**: An instance overrides a non-locked source via `InstanceNativeAlarmSourceOverride`, keyed by `SourceCanonicalName`. `ConnectionNameOverride`, `SourceReferenceOverride`, and `ConditionFilterOverride` are individually overridable — each is applied only when non-null; a null field **keeps the inherited value**. Name is fixed. Lock applies to the entire source. - **Scripts**: C# source code, Trigger configuration, minimum time between runs, and parameter/return definitions are overridable. Name is fixed. Lock applies to the entire script. - **Composed module members**: A composing template or child template can override non-locked members inside a composed module using the canonical path-qualified name. diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index 334a6a27..4b4b2b92 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -823,6 +823,11 @@ scadabridge --url instance alarm-override set --instance-id --alarm | `--priority` | no | Priority override (0–1000) | | `--mes-reportable` | no | MES-reportable override: `true` or `false`; omit to inherit the template value | +`set` replaces the **whole** override row, so an omitted flag is not "leave as is" — it clears that +field back to inherited. `instance alarm-override set --instance-id 6 --alarm X --mes-reportable true` +wipes any existing `--priority` / `--trigger-config` override on alarm `X`; pass every override you +want to keep on the same command (`instance alarm-override list` shows the current values). + #### `instance alarm-override delete` Remove an alarm override on an instance. diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs index e559ab45..736cbea0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs @@ -1108,6 +1108,7 @@ public class TemplateService TriggerType = alarm.TriggerType, TriggerConfiguration = alarm.TriggerConfiguration, OnTriggerScriptId = alarm.OnTriggerScriptId, + IsMesReportable = alarm.IsMesReportable, IsInherited = true, LockedInDerived = false, }); @@ -1385,6 +1386,7 @@ public class TemplateService TriggerType = win.TriggerType, TriggerConfiguration = m.EffectiveTriggerConfiguration, OnTriggerScriptId = win.OnTriggerScriptId, + IsMesReportable = win.IsMesReportable, IsInherited = true, LockedInDerived = false, }, ct); @@ -1396,7 +1398,8 @@ public class TemplateService existing.Description != win.Description || existing.OnTriggerScriptId != win.OnTriggerScriptId || existing.TriggerType != win.TriggerType || - existing.IsLocked != win.IsLocked)) + existing.IsLocked != win.IsLocked || + existing.IsMesReportable != win.IsMesReportable)) { existing.PriorityLevel = win.PriorityLevel; existing.TriggerConfiguration = m.EffectiveTriggerConfiguration; @@ -1404,6 +1407,7 @@ public class TemplateService existing.OnTriggerScriptId = win.OnTriggerScriptId; existing.TriggerType = win.TriggerType; existing.IsLocked = win.IsLocked; + existing.IsMesReportable = win.IsMesReportable; await _repository.UpdateTemplateAlarmAsync(existing, ct); counts.Updated++; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/CompatibilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/CompatibilityTests.cs index b9508d20..dcbae05c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/CompatibilityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/CompatibilityTests.cs @@ -285,6 +285,29 @@ public class CompatibilityTests Assert.Null(deserialized!.AckTime); } + [Fact] + public void BackwardCompat_AlarmStateChanged_MissingIsMesReportable_DefaultsToFalse() + { + // A frame minted by a node predating the MES-reportable flag omits the property + // entirely; it must deserialize to "not reported to MES", not fail. False is the + // safe default — an old site's alarms stay out of the MES alarm-status API until + // it is upgraded and stamps the flag itself. + var json = """ + { + "InstanceUniqueName": "inst-1", + "AlarmName": "Tank01.Level.HiHi", + "State": 1, + "Priority": 900, + "Timestamp": "2026-08-01T00:00:00+00:00" + } + """; + + var deserialized = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(deserialized); + Assert.False(deserialized!.IsMesReportable); + } + [Fact] public void RoundTrip_HeartbeatMessage_Succeeds() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs index 1aa63ee5..dc45198b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs @@ -495,6 +495,43 @@ public class FlatteningServiceTests Assert.Equal("{\"attributeName\":\"Temp\",\"high\":100}", alarm.TriggerConfiguration); } + [Fact] + public void Flatten_MesReportable_DerivedChain_BaseValueWins() + { + // A stale IsInherited placeholder must not be able to un-report an alarm: + // the base's IsMesReportable is the resolved value down the chain. + var baseTemplate = CreateTemplate(2, "Sensor"); + baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp") + { + TriggerType = AlarmTriggerType.RangeViolation, + TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}", + PriorityLevel = 5, + IsMesReportable = true + }); + + var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2); + derived.Alarms.Add(new TemplateAlarm("HighTemp") + { + TriggerType = AlarmTriggerType.RangeViolation, + TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}", + PriorityLevel = 5, + IsInherited = true, + IsMesReportable = false + }); + + var instance = CreateInstance(); + var result = _sut.Flatten( + instance, + [derived, baseTemplate], + new Dictionary>(), + new Dictionary>(), + new Dictionary()); + + Assert.True(result.IsSuccess); + var alarm = result.Value.Alarms.First(a => a.CanonicalName == "HighTemp"); + Assert.True(alarm.IsMesReportable); + } + [Fact] public void Flatten_OverriddenAlarmOnDerived_DerivedValueWins() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs index e00ac6a3..17d273f2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/TemplateServiceTests.cs @@ -2046,4 +2046,108 @@ public class TemplateServiceTests Assert.Contains(added, a => a.TemplateId == 7 && a.Name == "C" && !a.IsInherited); // base's own Assert.Contains(added, a => a.TemplateId == 8 && a.Name == "C" && a.IsInherited); // child placeholder } + + [Fact] + public async Task CreateDerivedTemplateAsync_CopiesIsMesReportableOntoPlaceholder() + { + // Deriving a template (compose → BuildDerivedTemplate) materializes the + // base's alarms as IsInherited placeholders; the MES-reportable flag has + // to ride along or the derived template reads "not reportable" and an + // edit to any other field would persist that false. + var moduleTemplate = new Template("Module") { Id = 2 }; + moduleTemplate.Alarms.Add(new TemplateAlarm("HighTemp") + { + Id = 30, + TemplateId = 2, + TriggerType = AlarmTriggerType.RangeViolation, + TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}", + PriorityLevel = 5, + Description = "Too hot", + IsMesReportable = true + }); + var template = new Template("Parent") { Id = 1 }; + + _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny())).ReturnsAsync(template); + _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny())).ReturnsAsync(moduleTemplate); + _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny())) + .ReturnsAsync(new List