#!/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"