From 421d2d305e64ce7ff0bd5e0fb3cfc813f700a2c7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 11 Sep 2026 10:33:40 -0400 Subject: [PATCH] =?UTF-8?q?feat(docker):=20seed=20+=20verify=20scripts=20f?= =?UTF-8?q?or=20the=20MES-reportable=20alarm=20demo=20=E2=80=94=20rig=20li?= =?UTF-8?q?ve=20gate=20PASS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 13/14. seed-mes-alarm-demo.sh builds a template whose three computed alarms differ in how they acquire the flag (template --mes-reportable, nothing, instance override over a template false), a site Call script projecting them through Alarms.CurrentAsync() / MesReportableAsync(), and two inbound methods + a scoped API key. Idempotent by name lookup. verify-mes-alarm-demo.sh drives the real inbound HTTP surface: PASS 14/14 on the docker rig (migration 20260911135501_AddAlarmMesReportable applied at boot). Playwright AlarmOverride_MesReportable_RoundTrips passed. Results + the facts the gate established are in design doc section 8. Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB --- docker/seed-mes-alarm-demo.sh | 263 ++++++++++++++++++ docker/verify-mes-alarm-demo.sh | 158 +++++++++++ ...-09-11-mes-reportable-alarm-flag-design.md | 77 +++++ 3 files changed, 498 insertions(+) create mode 100755 docker/seed-mes-alarm-demo.sh create mode 100755 docker/verify-mes-alarm-demo.sh diff --git a/docker/seed-mes-alarm-demo.sh b/docker/seed-mes-alarm-demo.sh new file mode 100755 index 00000000..42023453 --- /dev/null +++ b/docker/seed-mes-alarm-demo.sh @@ -0,0 +1,263 @@ +#!/bin/bash +set -euo pipefail + +# Seed the MES-REPORTABLE ALARM demo on the docker rig +# (design docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md, Task 13): one template whose +# three computed alarms differ in how they acquire the MES-reportable flag, one site Call script +# that projects them through the new script API, and two inbound methods that a MES client calls. +# +# What it proves end-to-end: +# * `template alarm add --mes-reportable` -> ScriptAlarm.IsMesReportable == true (HighTemp) +# * no flag on the template -> ScriptAlarm.IsMesReportable == false (TempSpike) +# * `instance alarm-override set --mes-reportable true` BEATS a template false (SensorFault) +# * `Alarms.MesReportableAsync()` is the pre-filtered read; `Alarms.CurrentAsync()` is not. +# +# What it creates (site-a / site id 1 — everything is static, so no data connection is needed): +# - Template MesAlarmDemo +# * Temperature (Double, STATIC, value 20) — writing it trips the computed alarms synchronously +# * HighTemp HiLo hi=80 hihi=95 priority 500 --mes-reportable +# * TempSpike ValueMatch match=100 priority 300 (not reportable) +# * SensorFault ValueMatch match=-1 priority 700 (not reportable ON THE TEMPLATE) +# * AlarmStatus (Call) — the MES alarm-status projection, with the filter parameters +# - Instance MesAlarmDemo01 on site 1, with an alarm override making SensorFault reportable +# - Inbound methods AlarmStatus (full filter) and SimpleAlarmStatus (flagged-only shorthand), +# both routing to the site script via Route.To(MachineCode) +# - API key "mes-alarm-demo" scoped to both methods — the token is printed ONCE +# +# IDEMPOTENT: every object is looked up by name first; scripts and methods are UPDATED in place. +# 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. +# +# Live gate: MES_ALARM_DEMO_KEY= bash docker/verify-mes-alarm-demo.sh + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge" +A=(--url "${SCADABRIDGE_URL:-http://localhost:9000}" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}") +SITE_ID="${SITE_ID:-1}" +TEMPLATE_NAME="${TEMPLATE_NAME:-MesAlarmDemo}" +INSTANCE_NAME="${INSTANCE_NAME:-MesAlarmDemo01}" +KEY_NAME="${KEY_NAME:-mes-alarm-demo}" + +json_id() { python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'; } +# stdin = JSON array, $1 = key, $2 = value -> the matching row's id, or "" when absent +find_id_opt() { + python3 -c 'import json,sys +k,v=sys.argv[1],sys.argv[2] +print(next((str(x["id"]) for x in json.load(sys.stdin) if str(x.get(k))==v), ""))' "$1" "$2" +} +# stdin = template get JSON, $1 = child collection ("attributes"/"alarms"/"scripts"), $2 = name +child_id() { + python3 -c 'import json,sys +t=json.load(sys.stdin); coll,name=sys.argv[1],sys.argv[2] +print(next((str(c["id"]) for c in (t.get(coll) or []) if c.get("name")==name), ""))' "$1" "$2" +} + +# The MES alarm-status projection. Parameters are all optional except MachineCode; the filter +# arrives from the inbound method's AlarmFilter object flattened into scalars. +# +# Two things worth knowing when reading this: +# * `Active && !IsConfiguredPlaceholder` is the canonical "really in alarm" filter — the +# snapshot retains a row per configured alarm, including cleared ones and the placeholder +# rows that stand in for native source bindings holding no conditions. +# * COMPUTED alarms report Acknowledged == true and AckTime == null by construction +# (AlarmConditionStateFactory.ForComputed) — there is no ack-back for them — so StatusCode +# reads "Triggered.Acked" and AckDT is null on this demo. Native (mirrored) conditions are +# where the ack fields carry real source data. +read -r -d '' SITE_SCRIPT_CODE <<'CS' || true +var code = Parameters["MachineCode"]?.ToString() ?? ""; +var flaggedOnly = Parameters.Get("FlaggedOnly") ?? false; +var includeAck = Parameters.Get("IncludeAcked") ?? true; +var minSev = Parameters.Get("MinSeverity"); +var maxSev = Parameters.Get("MaxSeverity"); +var nameFilter = Parameters.ContainsKey("NameFilter") ? Parameters["NameFilter"]?.ToString() : null; +try { + var alarms = flaggedOnly ? await Alarms.MesReportableAsync() : await Alarms.CurrentAsync(); + var infos = alarms + .Where(a => a.Active && !a.IsConfiguredPlaceholder) + .Where(a => minSev == null || a.Severity >= minSev) + .Where(a => maxSev == null || a.Severity <= maxSev) + .Where(a => string.IsNullOrEmpty(nameFilter) || a.Name.Contains(nameFilter, StringComparison.OrdinalIgnoreCase)) + .Where(a => includeAck || !a.Acknowledged) + .Select(a => new { + Name = a.Name, + HierarchicalName = code + "." + a.Name, + Description = string.IsNullOrEmpty(a.Message) ? a.AlarmTypeName : a.Message, + IsFlaggedForMES = a.IsMesReportable, + Severity = a.Severity, + StatusCode = a.Acknowledged ? "Triggered.Acked" : "Triggered", + TriggeredDT = (a.OriginalRaiseTime ?? a.Timestamp).ToString("o"), + AckDT = a.AckTime?.ToString("o"), + AckComment = a.OperatorComment }) + .ToList(); + return new { WasSuccessful = true, ErrorText = (string)null, Alarms = infos }; +} catch (Exception ex) { + return new { WasSuccessful = false, ErrorText = "AlarmStatus failed: " + ex.Message, Alarms = Array.Empty() }; +} +CS + +SITE_PARAMS='{"type":"object","properties":{"MachineCode":{"type":"string"},"FlaggedOnly":{"type":"boolean"},"IncludeAcked":{"type":"boolean"},"MinSeverity":{"type":"integer"},"MaxSeverity":{"type":"integer"},"NameFilter":{"type":"string"}},"required":["MachineCode"]}' +RETURN_DEF='{"type":"object","properties":{"WasSuccessful":{"type":"boolean"},"ErrorText":{"type":"string"},"Alarms":{"type":"array","items":{"type":"object","properties":{"Name":{"type":"string"},"HierarchicalName":{"type":"string"},"Description":{"type":"string"},"IsFlaggedForMES":{"type":"boolean"},"Severity":{"type":"integer"},"StatusCode":{"type":"string"},"TriggeredDT":{"type":"string"},"AckDT":{"type":"string"},"AckComment":{"type":"string"}}}}},"required":["WasSuccessful","Alarms"]}' + +# Inbound method: flatten the nested AlarmFilter object into the site script's scalar parameters. +# An object parameter materializes as Dictionary; integers arrive as long. +read -r -d '' METHOD_CODE <<'CS' || true +var code = (Parameters.ContainsKey("MachineCode") ? Parameters["MachineCode"]?.ToString() : null) ?? ""; +var f = Parameters.ContainsKey("AlarmFilter") ? Parameters["AlarmFilter"] as System.Collections.Generic.IDictionary : null; +object? G(string k) => f != null && f.TryGetValue(k, out var v) ? v : null; +try { + return await Route.To(code).Call("AlarmStatus", new { + MachineCode = code, + FlaggedOnly = G("FlaggedOnly") is bool fo && fo, + IncludeAcked = !(G("IncludeAcked") is bool ia) || ia, + MinSeverity = G("MinSeverity") is long mn ? (int?)(int)mn : null, + MaxSeverity = G("MaxSeverity") is long mx ? (int?)(int)mx : null, + NameFilter = G("NameFilter")?.ToString() }); +} catch (Exception ex) when (ex.Message.Contains("not found on instance")) { + return new { WasSuccessful = false, ErrorText = $"Alarm status is not supported on machine '{code}'", Alarms = Array.Empty() }; +} catch (Exception ex) { + return new { WasSuccessful = false, ErrorText = "AlarmStatus failed: " + ex.Message, Alarms = Array.Empty() }; +} +CS + +# Shorthand for a MES client that only ever wants the flagged alarms. +read -r -d '' SIMPLE_METHOD_CODE <<'CS' || true +var code = (Parameters.ContainsKey("MachineCode") ? Parameters["MachineCode"]?.ToString() : null) ?? ""; +try { + return await Route.To(code).Call("AlarmStatus", new { + MachineCode = code, FlaggedOnly = true, IncludeAcked = true, + MinSeverity = (int?)null, MaxSeverity = (int?)null, NameFilter = (string)null }); +} catch (Exception ex) when (ex.Message.Contains("not found on instance")) { + return new { WasSuccessful = false, ErrorText = $"Alarm status is not supported on machine '{code}'", Alarms = Array.Empty() }; +} catch (Exception ex) { + return new { WasSuccessful = false, ErrorText = "AlarmStatus failed: " + ex.Message, Alarms = Array.Empty() }; +} +CS + +METHOD_PARAMS='{"type":"object","properties":{"MachineCode":{"type":"string"},"AlarmFilter":{"type":"object","properties":{"FlaggedOnly":{"type":"boolean"},"IncludeAcked":{"type":"boolean"},"MinSeverity":{"type":"integer"},"MaxSeverity":{"type":"integer"},"NameFilter":{"type":"string"}}}},"required":["MachineCode"]}' +SIMPLE_METHOD_PARAMS='{"type":"object","properties":{"MachineCode":{"type":"string"}},"required":["MachineCode"]}' + +echo "1/8 Template $TEMPLATE_NAME..." +TEMPLATE_ID=$("$CLI" template list "${A[@]}" | find_id_opt name "$TEMPLATE_NAME") +if [ -z "$TEMPLATE_ID" ]; then + TEMPLATE_ID=$("$CLI" template create "${A[@]}" --name "$TEMPLATE_NAME" \ + --description "MES-reportable alarm flag demo (docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md)" | json_id) + echo " created id=$TEMPLATE_ID" +else + echo " exists id=$TEMPLATE_ID" +fi + +echo "2/8 Static attribute Temperature (Double, 20)..." +if [ -z "$("$CLI" template get "${A[@]}" --id "$TEMPLATE_ID" | child_id attributes Temperature)" ]; then + "$CLI" template attribute add "${A[@]}" --template-id "$TEMPLATE_ID" --name Temperature \ + --data-type Double --value 20 --description "Static process value; writing it trips the computed alarms synchronously" > /dev/null + echo " added" +else + echo " exists" +fi + +echo "3/8 Alarms HighTemp / TempSpike / SensorFault..." +TPL_JSON=$("$CLI" template get "${A[@]}" --id "$TEMPLATE_ID") +if [ -z "$(echo "$TPL_JSON" | child_id alarms HighTemp)" ]; then + "$CLI" template alarm add "${A[@]}" --template-id "$TEMPLATE_ID" --name HighTemp --trigger-type HiLo \ + --attribute Temperature --hi 80 --hihi 95 --priority 500 --mes-reportable \ + --description "High temperature — reportable to MES from the template" > /dev/null + echo " added HighTemp (mes-reportable)" +else + echo " HighTemp exists" +fi +if [ -z "$(echo "$TPL_JSON" | child_id alarms TempSpike)" ]; then + "$CLI" template alarm add "${A[@]}" --template-id "$TEMPLATE_ID" --name TempSpike --trigger-type ValueMatch \ + --attribute Temperature --match-value 100 --priority 300 \ + --description "Exact spike value — deliberately NOT reportable" > /dev/null + echo " added TempSpike (not reportable)" +else + echo " TempSpike exists" +fi +if [ -z "$(echo "$TPL_JSON" | child_id alarms SensorFault)" ]; then + # --match-value=-1 (not a space) so the leading dash is not parsed as another option. + "$CLI" template alarm add "${A[@]}" --template-id "$TEMPLATE_ID" --name SensorFault --trigger-type ValueMatch \ + --attribute Temperature --match-value=-1 --priority 700 \ + --description "Sensor fault sentinel — not reportable on the template; the INSTANCE overrides it to true" > /dev/null + echo " added SensorFault (not reportable on the template)" +else + echo " SensorFault exists" +fi + +echo "4/8 Site script AlarmStatus (Call)..." +SCRIPT_ID=$("$CLI" template get "${A[@]}" --id "$TEMPLATE_ID" | child_id scripts AlarmStatus) +if [ -n "$SCRIPT_ID" ]; then + "$CLI" template script update "${A[@]}" --id "$SCRIPT_ID" --name AlarmStatus --trigger-type Call \ + --parameters "$SITE_PARAMS" --return-def "$RETURN_DEF" --code "$SITE_SCRIPT_CODE" > /dev/null + echo " updated id=$SCRIPT_ID" +else + SCRIPT_ID=$("$CLI" template script add "${A[@]}" --template-id "$TEMPLATE_ID" --name AlarmStatus --trigger-type Call \ + --parameters "$SITE_PARAMS" --return-def "$RETURN_DEF" --code "$SITE_SCRIPT_CODE" | json_id) + echo " added id=$SCRIPT_ID" +fi + +echo "5/8 Instance $INSTANCE_NAME on site $SITE_ID..." +INSTANCE_ID=$("$CLI" instance list "${A[@]}" | find_id_opt uniqueName "$INSTANCE_NAME") +if [ -z "$INSTANCE_ID" ]; then + INSTANCE_ID=$("$CLI" instance create "${A[@]}" --name "$INSTANCE_NAME" --template-id "$TEMPLATE_ID" --site-id "$SITE_ID" | json_id) + echo " created id=$INSTANCE_ID" +else + echo " exists id=$INSTANCE_ID" +fi + +echo "6/8 Instance alarm override: SensorFault --mes-reportable true (beats the template false)..." +"$CLI" instance alarm-override set "${A[@]}" --instance-id "$INSTANCE_ID" --alarm SensorFault --mes-reportable true > /dev/null +"$CLI" instance deploy "${A[@]}" --id "$INSTANCE_ID" > /dev/null +echo " override set and instance deployed" + +echo "7/8 Inbound methods AlarmStatus / SimpleAlarmStatus..." +UPSERTED_METHOD_ID="" +upsert_method() { # $1 = name, $2 = params schema, $3 = script -> sets UPSERTED_METHOD_ID + local existing + existing=$("$CLI" api-method list "${A[@]}" | find_id_opt name "$1") + if [ -n "$existing" ]; then + # KNOWN TRAP: a non-compiling UPDATE keeps serving the OLD delegate silently. The + # flow-debug smoke below is what actually proves the new body compiled and runs. + "$CLI" api-method update "${A[@]}" --id "$existing" --timeout 30 \ + --parameters "$2" --return-def "$RETURN_DEF" --script "$3" > /dev/null + echo " updated $1 id=$existing" + else + existing=$("$CLI" api-method create "${A[@]}" --name "$1" --timeout 30 \ + --parameters "$2" --return-def "$RETURN_DEF" --script "$3" | json_id) + echo " created $1 id=$existing" + fi + UPSERTED_METHOD_ID="$existing" +} +upsert_method AlarmStatus "$METHOD_PARAMS" "$METHOD_CODE"; METHOD_ID="$UPSERTED_METHOD_ID" +upsert_method SimpleAlarmStatus "$SIMPLE_METHOD_PARAMS" "$SIMPLE_METHOD_CODE"; SIMPLE_METHOD_ID="$UPSERTED_METHOD_ID" + +echo "8/8 API key '$KEY_NAME' scoped to AlarmStatus,SimpleAlarmStatus..." +EXISTING_KEY=$("$CLI" security api-key list "${A[@]}" | python3 -c ' +import json,sys +name=sys.argv[1] +rows=json.load(sys.stdin) +print("yes" if any((r.get("name") or r.get("displayName"))==name for r in rows) else "")' "$KEY_NAME") +if [ -n "$EXISTING_KEY" ]; then + echo " key '$KEY_NAME' already exists — the token cannot be re-shown." + echo " Re-use the MES_ALARM_DEMO_KEY from the first run, or delete the key" + echo " (security api-key delete --key-id ...) and re-run this script." +else + KEY_JSON=$("$CLI" security api-key create "${A[@]}" --name "$KEY_NAME" --methods AlarmStatus,SimpleAlarmStatus) + TOKEN=$(echo "$KEY_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])' 2>/dev/null \ + || echo "$KEY_JSON" | grep -o 'sbk_[A-Za-z0-9_-]*' | head -1) + echo "" + echo "MES_ALARM_DEMO_KEY=$TOKEN" +fi + +echo "" +echo "MES_ALARM_DEMO_TEMPLATE_ID=$TEMPLATE_ID MES_ALARM_DEMO_INSTANCE_ID=$INSTANCE_ID" +echo "MES_ALARM_DEMO_METHOD_ID=$METHOD_ID MES_ALARM_DEMO_SIMPLE_METHOD_ID=$SIMPLE_METHOD_ID" +echo "" +echo "Smoke (also the compile check an api-method UPDATE does not give you):" +echo " $CLI flow-debug run-method --method-id $METHOD_ID --params '{\"MachineCode\":\"$INSTANCE_NAME\"}' ${A[*]}" +echo "Trip an alarm / clear it:" +echo " $CLI flow-debug run-value-change --instance-id $INSTANCE_ID --attribute Temperature --value 96 ${A[*]}" +echo " $CLI flow-debug run-value-change --instance-id $INSTANCE_ID --attribute Temperature --value 20 ${A[*]}" +echo "Live gate: MES_ALARM_DEMO_KEY= bash $SCRIPT_DIR/verify-mes-alarm-demo.sh" diff --git a/docker/verify-mes-alarm-demo.sh b/docker/verify-mes-alarm-demo.sh new file mode 100755 index 00000000..92b8b7c7 --- /dev/null +++ b/docker/verify-mes-alarm-demo.sh @@ -0,0 +1,158 @@ +#!/bin/bash +set -euo pipefail + +# Live gate for the MES-REPORTABLE ALARM flag (design +# docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md, Task 14). Drives the objects seeded by +# docker/seed-mes-alarm-demo.sh through the REAL inbound HTTP surface (Traefik -> active central -> +# site script -> Instance Actor alarm snapshot), so every assertion below is end-to-end. +# +# Steps: +# A 96 -> AlarmStatus : only HighTemp, IsFlaggedForMES true (template flag) +# B 100 -> AlarmStatus : HighTemp(true) + TempSpike(false) unfiltered; +# FlaggedOnly=true drops TempSpike and keeps HighTemp +# C -1 -> SimpleAlarmStatus : SensorFault true (INSTANCE override beats template false), +# HighTemp absent (-1 trips no high band) +# D -1 -> AlarmStatus MinSeverity : 600 leaves SensorFault (700) only +# E 20 -> AlarmStatus : WasSuccessful true, zero alarms +# F DelmiaSim01 : machine without the script -> "not supported on machine" +# G NoSuchMachine : unknown machine -> "not found" +# +# Usage: MES_ALARM_DEMO_KEY= bash docker/verify-mes-alarm-demo.sh + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge" +URL="${SCADABRIDGE_URL:-http://localhost:9000}" +A=(--url "$URL" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}") +KEY="${MES_ALARM_DEMO_KEY:?set MES_ALARM_DEMO_KEY to the token printed by docker/seed-mes-alarm-demo.sh}" +INSTANCE_NAME="${INSTANCE_NAME:-MesAlarmDemo01}" +# A deployed instance that does NOT have an AlarmStatus script (docker/seed-delmia-sim.sh). +OTHER_INSTANCE="${OTHER_INSTANCE:-DelmiaSim01}" + +PASS=0; FAIL=0 +pass() { PASS=$((PASS+1)); echo " PASS $*"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL $*"; } + +find_id_opt() { + python3 -c 'import json,sys +k,v=sys.argv[1],sys.argv[2] +print(next((str(x["id"]) for x in json.load(sys.stdin) if str(x.get(k))==v), ""))' "$1" "$2" +} + +INSTANCE_ID=$("$CLI" instance list "${A[@]}" | find_id_opt uniqueName "$INSTANCE_NAME") +[ -n "$INSTANCE_ID" ] || { echo "instance $INSTANCE_NAME not found — run docker/seed-mes-alarm-demo.sh first"; exit 1; } +echo "instance $INSTANCE_NAME id=$INSTANCE_ID" + +trip() { # $1 = Temperature value; the write trips the computed alarms synchronously at the site + "$CLI" flow-debug run-value-change "${A[@]}" --instance-id "$INSTANCE_ID" --attribute Temperature --value="$1" > /dev/null + sleep 2 +} + +call() { # $1 = method name, $2 = JSON body -> response body on stdout + curl -sS -X POST "$URL/api/$1" -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d "$2" +} + +# $1 = response JSON, $2.. = python assertion expressions over `d` (the parsed response) and +# `alarms` (a {name: row} map); prints OK or the offending value. +assert() { # $1 = label, $2 = response, $3 = python expression + local out + out=$(python3 -c ' +import json,sys +d=json.loads(sys.argv[1]) +body=d.get("data", d) if isinstance(d, dict) else d +alarms={a["Name"]: a for a in (body.get("Alarms") or [])} +names=sorted(alarms) +ok=eval(sys.argv[2]) +print("OK" if ok else "got WasSuccessful=%r names=%r rows=%s" % (body.get("WasSuccessful"), names, json.dumps([alarms[n] for n in names])[:600])) +' "$2" "$3" 2>&1) || { fail "$1 (assertion error: $out)"; return; } + if [ "$out" = "OK" ]; then pass "$1"; else fail "$1 — $out"; fi +} + +echo "" +echo "=== A: Temperature 96 -> HighTemp only, flagged by the TEMPLATE ===" +trip 96 +RA=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\"}") +assert "A1 WasSuccessful true, exactly 1 alarm, named HighTemp" "$RA" \ + 'body.get("WasSuccessful") is True and names==["HighTemp"]' +# StatusCode is "Triggered.Acked", not "Triggered": AlarmConditionStateFactory.ForComputed reports +# Acknowledged == true for every computed alarm (there is no ack-back for them), and AckDT is null. +assert "A2 HighTemp IsFlaggedForMES true, Severity 500, StatusCode Triggered.Acked, AckDT null" "$RA" \ + 'alarms["HighTemp"]["IsFlaggedForMES"] is True and alarms["HighTemp"]["Severity"]==500 and alarms["HighTemp"]["StatusCode"]=="Triggered.Acked" and alarms["HighTemp"]["AckDT"] is None' +assert "A3 HierarchicalName is ." "$RA" \ + 'alarms["HighTemp"]["HierarchicalName"]=="'"$INSTANCE_NAME"'.HighTemp"' +# H (design §6 step 7): cross-check the API answer against the site's own view of the same moment. +# NOTE the snapshot's own `isMesReportable` is always false here and that is BY DESIGN — the flag is +# deliberately not on the gRPC protos in this slice (design §2.2), so central never sees it; only the +# site-local script snapshot is stamped. The cross-check is therefore about alarm ACTIVITY. +SNAP=$("$CLI" debug snapshot "${A[@]}" --id "$INSTANCE_ID" 2>/dev/null || echo '{}') +HSTATE=$(echo "$SNAP" | python3 -c ' +import json,sys +d=json.load(sys.stdin) +rows=(d.get("data") or d).get("alarmStates") or [] +r=next((r for r in rows if r.get("alarmName")=="HighTemp"), None) +# AlarmState: Active=0, Normal=1; condition.active is the same fact spelled out. +print("MISSING" if r is None else ("Active" if (r.get("condition") or {}).get("active") or r.get("state")==0 else "Normal"))' 2>/dev/null || echo "MISSING") +if [ "$HSTATE" = "Active" ]; then pass "H debug snapshot shows HighTemp Active"; else fail "H debug snapshot HighTemp state=$HSTATE"; fi + +echo "" +echo "=== B: Temperature 100 -> HighTemp(HiHi) + TempSpike; FlaggedOnly drops TempSpike ===" +trip 100 +RB=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\",\"AlarmFilter\":{\"FlaggedOnly\":false}}") +assert "B1 unfiltered: HighTemp + TempSpike" "$RB" \ + 'body.get("WasSuccessful") is True and names==["HighTemp","TempSpike"]' +assert "B2 HighTemp flagged true, TempSpike flagged false" "$RB" \ + 'alarms["HighTemp"]["IsFlaggedForMES"] is True and alarms["TempSpike"]["IsFlaggedForMES"] is False' +RB2=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\",\"AlarmFilter\":{\"FlaggedOnly\":true}}") +assert "B3 FlaggedOnly=true: HighTemp present, TempSpike absent" "$RB2" \ + 'body.get("WasSuccessful") is True and names==["HighTemp"]' + +echo "" +echo "=== C: Temperature -1 -> SensorFault reportable by INSTANCE OVERRIDE ===" +trip -1 +RC=$(call SimpleAlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\"}") +assert "C1 SimpleAlarmStatus returns SensorFault only (flagged-only)" "$RC" \ + 'body.get("WasSuccessful") is True and names==["SensorFault"]' +assert "C2 SensorFault IsFlaggedForMES true (override beats template false), Severity 700" "$RC" \ + 'alarms["SensorFault"]["IsFlaggedForMES"] is True and alarms["SensorFault"]["Severity"]==700' +RC2=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\"}") +assert "C3 unfiltered at -1: HighTemp absent, SensorFault present" "$RC2" \ + 'names==["SensorFault"]' + +echo "" +echo "=== D: MinSeverity 600 keeps SensorFault (700) only ===" +RD=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\",\"AlarmFilter\":{\"MinSeverity\":600}}") +assert "D1 MinSeverity=600 -> SensorFault only" "$RD" \ + 'body.get("WasSuccessful") is True and names==["SensorFault"] and alarms["SensorFault"]["Severity"]>=600' + +echo "" +echo "=== E: Temperature 20 -> everything clear ===" +trip 20 +RE=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\"}") +assert "E1 WasSuccessful true with zero alarms" "$RE" \ + 'body.get("WasSuccessful") is True and names==[]' + +echo "" +echo "=== F: a machine without the AlarmStatus script ===" +OTHER_ID=$("$CLI" instance list "${A[@]}" | find_id_opt uniqueName "$OTHER_INSTANCE") +if [ -z "$OTHER_ID" ]; then + echo " SKIP $OTHER_INSTANCE is not on this rig (docker/seed-delmia-sim.sh not run) — step F skipped" +else + RF=$(call AlarmStatus "{\"MachineCode\":\"$OTHER_INSTANCE\"}") + assert "F1 WasSuccessful false, ErrorText says not supported on machine" "$RF" \ + 'body.get("WasSuccessful") is False and "not supported on machine" in (body.get("ErrorText") or "")' +fi + +echo "" +echo "=== G: an unknown machine ===" +RG=$(call AlarmStatus '{"MachineCode":"NoSuchMachine"}') +assert "G1 WasSuccessful false, ErrorText contains 'not found'" "$RG" \ + 'body.get("WasSuccessful") is False and "not found" in (body.get("ErrorText") or "")' + +echo "" +TOTAL=$((PASS+FAIL)) +if [ "$FAIL" -eq 0 ]; then + echo "PASS $PASS/$TOTAL" +else + echo "FAIL $FAIL of $TOTAL (passed $PASS/$TOTAL)" + exit 1 +fi diff --git a/docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md b/docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md index 8c6a77fd..8e8ebcec 100644 --- a/docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md +++ b/docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md @@ -235,3 +235,80 @@ Central inbound method AlarmStatus ── Route.To(MachineCode).Call("AlarmStatu editor), `Component-ConfigurationDatabase.md` (migration), CLI `README.md`, `CLAUDE.md` native-alarm bullet (flag vs band), `2026-06-30-mes-alarm-status-api.md` §6.1 (note: superseded for computed alarms), deferred-work register (native source-level flag; central badge/proto). + +--- + +## 8. Rig live gate results (2026-09-11) + +Seeded by `docker/seed-mes-alarm-demo.sh`, gated by `docker/verify-mes-alarm-demo.sh` against the +`docker/` rig (8 nodes, rebuilt from this branch). Config DB migration applied on boot: + +``` +[14:19:43 INF] [Central/scadabridge-central-a] Applying migration '20260911135501_AddAlarmMesReportable'. +``` + +### 8.1 Seeded objects + +| Object | Id | Notes | +|---|---|---| +| Template `MesAlarmDemo` | 5255 | `Temperature` Double **static**, value 20 | +| Alarm `HighTemp` | — | HiLo hi=80 hihi=95, priority 500, **`--mes-reportable`** | +| Alarm `TempSpike` | — | ValueMatch `100`, priority 300, not reportable | +| Alarm `SensorFault` | — | ValueMatch `-1`, priority 700, **not reportable on the template** | +| Site script `AlarmStatus` (Call) | 1045 | `Alarms.CurrentAsync()` / `Alarms.MesReportableAsync()` + the filter params | +| Instance `MesAlarmDemo01` (site-a) | 3847 | `instance alarm-override set --alarm SensorFault --mes-reportable true` | +| Inbound method `AlarmStatus` | 2224 | nested `AlarmFilter` object param, routes via `Route.To(MachineCode)` | +| Inbound method `SimpleAlarmStatus` | 2225 | flagged-only shorthand | +| API key `mes-alarm-demo` | — | scoped to both methods; token printed once | + +Second seed run reported `exists` for template/attribute/all three alarms/instance, `updated` for +both scripts and both methods, and refused to mint a second API key — **idempotent**, same ids. + +### 8.2 Gate: `PASS 14/14` + +| Step | Stimulus | Result | +|---|---|---| +| A1 | `96` → `AlarmStatus` | `WasSuccessful=true`, exactly 1 row, `HighTemp` | +| A2 | same | `IsFlaggedForMES=true` (template flag), `Severity=500`, `StatusCode="Triggered.Acked"`, `AckDT=null` | +| A3 | same | `HierarchicalName="MesAlarmDemo01.HighTemp"` | +| H | same moment | `debug snapshot --id 3847` shows `HighTemp` Active (`condition.active=true`) | +| B1/B2 | `100` → `FlaggedOnly=false` | 2 rows: `HighTemp` **true**, `TempSpike` **false** (HiHi still active at 100, so both) | +| B3 | `100` → `FlaggedOnly=true` | 1 row, `HighTemp`; `TempSpike` excluded by `MesReportableAsync()` | +| C1/C2 | `-1` → `SimpleAlarmStatus` | 1 row, `SensorFault`, `IsFlaggedForMES=true`, `Severity=700` — **the instance override beat the template's `false`** | +| C3 | `-1` → `AlarmStatus` | `SensorFault` only; `HighTemp` absent (−1 trips no high band) | +| D1 | `-1` + `MinSeverity=600` | `SensorFault` only (700 ≥ 600; HighTemp 500 and TempSpike 300 filtered) | +| E1 | `20` | `WasSuccessful=true`, `Alarms=[]` | +| F1 | `MachineCode=DelmiaSim01` | `WasSuccessful=false`, `ErrorText` contains "not supported on machine" (`not found on instance` catch) | +| G1 | `MachineCode=NoSuchMachine` | `WasSuccessful=false`, `ErrorText` contains "not found" | + +Browser round-trip: `dotnet test … --filter AlarmOverride_MesReportable_RoundTrips` → +**Passed: 1, Failed: 0** (12 s). + +### 8.3 Facts the gate established (read before extending the demo) + +- **`StatusCode` is `"Triggered.Acked"` for every computed alarm, and `AckDT` is always `null`.** + `AlarmConditionStateFactory.ForComputed` reports `Acknowledged: true` — computed alarms have no + ack-back, so there is nothing to be unacknowledged about. A filter such as the script's + `IncludeAcked=false` therefore drops *every* computed alarm; it is only meaningful over mirrored + native conditions. The plan's expected `"Triggered"` was wrong and the assertion was corrected. +- **`Severity` is the alarm's effective priority**, and for a HiLo alarm it is the *band* priority + (`LevelPriority`), falling back to the alarm priority when no per-band priority is configured — + which is why `HighTemp` reads 500 at HiHi and `MinSeverity=600` cleanly isolates `SensorFault`. +- **The `debug snapshot` / stream DTO reports `isMesReportable: false` for every alarm, including + `HighTemp`.** That is §2.2 working as specified — the flag is deliberately not on the gRPC protos + in this slice, so central never sees it and only the site-local script snapshot is stamped. Step H + therefore cross-checks alarm *activity*, not the flag. (Register item: central badge/proto.) +- **`ValueMatch` compares `value?.ToString()` as an ordinal string**, so `-1` and `100` matched the + Double attribute's rendered form on this rig. A ValueMatch alarm over a Double is a string + comparison, not a numeric one — `RangeViolation` is the numeric trigger. +- `--match-value=-1` must use the `=` form; a space-separated `-1` is parsed as another option. + +### 8.4 Deviations from the plan + +1. Step A's `StatusCode` expectation changed from `"Triggered"` to `"Triggered.Acked"` (§8.3). +2. Step H parses `alarmStates[].condition.active` (the snapshot has no textual state field; the + `state` enum is `Active=0`, `Normal=1`). +3. `SensorFault` is seeded with `--attribute Temperature` — a `ValueMatch` trigger needs the watched + attribute, which the plan's sketch omitted. +4. `site deploy-artifacts` is not called: the demo has no shared scripts or system-wide artifacts, + and `instance deploy` after the override is what puts the alarms and the script on site-a.