deploy(vd03): fe89363b + migrations 56/57; in-memory MES/Delmia/alarm-API test machines

vd03 (DEV) upgraded 2b9a7c3c -> fe89363b via the deployment-only winrm channel: COPY_ONLY backup,
55->57 delta script (AddAlarmMesReportable, AddSiteArtifactRevision), ~53 s window, snapshots
identical to baseline, artifacts deployed. Register row 126 now covers the QA pair only.

docker/seed-mes-test-machine.sh (idempotent) builds a standalone TestMesMachine base template -
Left/Right TestMESReceiver + TestDelmiaReceiver (prod attribute names, STATIC), computed alarms
with IsMesReportable, CvdReactor-compatible MesMoveIn/MesMoveOut/ProcessRecipeDownload/AlarmStatus
scripts ported to SetAsync+WaitAsync, and Galaxy-side simulators with reject/no-response knobs -
plus instances SBTEST01/02 and the SimpleAlarmStatusRequest (was a stub) / AlarmStatusRequest
inbound methods in the legacy /mes/(simple)alarmstatus shapes, resolving machines through BTDB.
BTDB rows SBTEST01=990001 / SBTEST02=990002 added on vd03.
docker/verify-mes-test-machine.sh drives the real inbound methods via flow-debug: PASS 25/25.
This commit is contained in:
Joseph Doherty
2026-09-28 10:10:17 -04:00
parent fe89363b1a
commit 0af0672597
4 changed files with 784 additions and 1 deletions
+532
View File
@@ -0,0 +1,532 @@
#!/bin/bash
set -euo pipefail
# Seed an IN-MEMORY MES / DELMIA / ALARM-API TEST MACHINE — a standalone stand-in for a CvdReactor-style
# machine that needs no Galaxy, no MxGateway and no bindings. Built for wonder-app-vd03 (DEV) but works
# against any ScadaBridge central (defaults to the docker rig on :9000).
#
# SCADABRIDGE_URL=http://wonder-app-vd03.zmr.zimmer.com:8085 SITE_ID=2 bash docker/seed-mes-test-machine.sh
#
# What it creates (everything STATIC — writes land in the instance's in-memory/SQLite state):
# - Template TestMESReceiver — the 26 prod MESReceiver attribute names (MoveIn*/MoveOut*/ScanStateCmd),
# ready flags default true.
# - Template TestDelmiaReceiver — the 10 prod DelmiaReceiver attribute names, ReadyFlag default true.
# - Template TestMesMachine (standalone base, NOT derived from CvdReactor) composing
# LeftMESReceiver + RightMESReceiver (TestMESReceiver) and DelmiaReceiver (TestDelmiaReceiver), with
# * simulator knobs SimNextBatchID, SimMoveInReject, SimMoveOutReject, SimRecipeReject, SimNoResponse
# * alarm source tags VacuumPressure, ArgonPressure, FurnaceTemp, DoorOpen, SensorStatus
# * computed alarms HighVacuumFault / LowArgonPressure / FurnaceOverTemp (MES-reportable),
# DoorOpenAlarm / SensorFault (not reportable on the template)
# * Call scripts MesMoveIn, MesMoveOut, ProcessRecipeDownload, AlarmStatus — same names,
# parameters and return shapes as the CvdReactor scripts the inbound methods
# route to, so the REAL MesMoveIn/MesMoveOut/DelmiaRecipeDownload methods work
# unchanged. WriteBatchAndWaitAsync only accepts data-sourced attributes, so the
# handshakes are ported to per-attribute SetAsync + WaitAsync.
# * Galaxy simulators SimLeft/RightMoveIn, SimLeft/RightMoveOut, SimDelmiaRecipe (ValueChange on the
# trigger flags) — they play the machine side and complete each handshake.
# - Instances SBTEST01 and SBTEST02 (SBTEST02 overrides SensorFault to MES-reportable), deployed.
# - Inbound methods SimpleAlarmStatusRequest (updated in place) and AlarmStatusRequest (legacy
# /mes/simplealarmstatus + /mes/alarmstatus shapes). Both resolve the machine through BTDB.dbo.Machine,
# so they need the "BTDB" database connection — present on vd03, absent on the docker rig.
#
# The MES methods resolve SAPID -> BTDB.dbo.Machine.Code, so each instance needs a BTDB row
# (Code = instance name). On vd03 those were inserted 2026-09-28: SBTEST01 = SAPID 990001 (MachineID 203),
# SBTEST02 = SAPID 990002 (MachineID 204), LastUpdateBy 'scadabridge-test-seed'.
#
# IDEMPOTENT: objects are looked up by name; scripts and methods are UPDATED in place; attributes, alarms,
# compositions and instances are only added when missing (edit those by hand, or delete + re-seed).
#
# Test recipes: docs/deployment/deployment-records/2026-09-28-wonder-app-vd03-fe89363b.md.
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}"
INSTANCES=(SBTEST01 SBTEST02)
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, $2 = name -> id or ""
child_id() {
python3 -c 'import json,sys
t=json.load(sys.stdin); coll,name=sys.argv[1],sys.argv[2]
key="instanceName" if coll=="compositions" else "name"
print(next((str(c["id"]) for c in (t.get(coll) or []) if c.get(key)==name and not c.get("isInherited")), ""))' "$1" "$2"
}
ensure_template() { # $1 name, $2 description -> id
local id
id=$("$CLI" template list "${A[@]}" | python3 -c 'import json,sys
n=sys.argv[1]
print(next((str(x["id"]) for x in json.load(sys.stdin) if x.get("name")==n and not x.get("parentTemplateId")), ""))' "$1")
if [ -z "$id" ]; then
id=$("$CLI" template create "${A[@]}" --name "$1" --description "$2" | json_id)
echo " created template $1 id=$id" >&2
else
echo " template $1 exists id=$id" >&2
fi
echo "$id"
}
ensure_attr() { # $1 template id, $2 name, then extra add args
local tid="$1" name="$2"; shift 2
if [ -z "$("$CLI" template get "${A[@]}" --id "$tid" | child_id attributes "$name")" ]; then
"$CLI" template attribute add "${A[@]}" --template-id "$tid" --name "$name" "$@" > /dev/null
echo " + attribute $name" >&2
fi
}
ensure_alarm() { # $1 template id, $2 name, then extra add args
local tid="$1" name="$2"; shift 2
if [ -z "$("$CLI" template get "${A[@]}" --id "$tid" | child_id alarms "$name")" ]; then
"$CLI" template alarm add "${A[@]}" --template-id "$tid" --name "$name" "$@" > /dev/null
echo " + alarm $name" >&2
fi
}
upsert_script() { # $1 template id, $2 name, then script args (--trigger-type ... --code ...)
local tid="$1" name="$2" sid; shift 2
sid=$("$CLI" template get "${A[@]}" --id "$tid" | child_id scripts "$name")
if [ -n "$sid" ]; then
"$CLI" template script update "${A[@]}" --id "$sid" --name "$name" "$@" > /dev/null
echo " ~ script $name (updated id=$sid)" >&2
else
"$CLI" template script add "${A[@]}" --template-id "$tid" --name "$name" "$@" > /dev/null
echo " + script $name" >&2
fi
}
ensure_composition() { # $1 template id, $2 composed template id, $3 instance name
if [ -z "$("$CLI" template get "${A[@]}" --id "$1" | child_id compositions "$3")" ]; then
"$CLI" template composition add "${A[@]}" --template-id "$1" --composed-template-id "$2" --instance-name "$3" > /dev/null
echo " + composition $3" >&2
fi
}
# ---------------------------------------------------------------------------------------------------
echo "1/7 Template TestMESReceiver (26 static attributes)..."
RX_ID=$(ensure_template TestMESReceiver "In-memory stand-in for the Galaxy MESReceiver object (prod attribute names, static)")
for dir in MoveIn MoveOut; do
ensure_attr "$RX_ID" "${dir}ReadyFlag" --data-type Boolean --value true --description "Precondition; true = machine accepts a ${dir}"
for f in Flag CompleteFlag SuccessfulFlag; do
ensure_attr "$RX_ID" "${dir}${f}" --data-type Boolean --value false --description "${dir} handshake flag"
done
ensure_attr "$RX_ID" "${dir}BatchID" --data-type Int32 --value 0 --description "${dir} batch id (written by the simulator)"
ensure_attr "$RX_ID" "${dir}NumberWorkOrders" --data-type Int32 --value 0 --description "${dir} payload"
for s in MesContainerNum ErrorText OperatorName Type; do
ensure_attr "$RX_ID" "${dir}${s}" --data-type String --description "${dir} payload / result text"
done
ensure_attr "$RX_ID" "${dir}WorkOrderNumbers" --data-type List --element-type String --description "${dir} payload"
ensure_attr "$RX_ID" "${dir}PartNumbers" --data-type List --element-type String --description "${dir} payload"
done
ensure_attr "$RX_ID" MoveInJobSequenceNumber --data-type String --description "MoveIn payload"
ensure_attr "$RX_ID" ScanStateCmd --data-type Boolean --value false --description "Unused by the simulator (prod parity)"
echo "2/7 Template TestDelmiaReceiver (10 static attributes)..."
DR_ID=$(ensure_template TestDelmiaReceiver "In-memory stand-in for the Galaxy DelmiaReceiver object (prod attribute names, static)")
ensure_attr "$DR_ID" ReadyFlag --data-type Boolean --value true --description "Precondition; true = receiver accepts downloads"
ensure_attr "$DR_ID" RecipeDownloadFlag --data-type Boolean --value false --description "Trigger: set true after the payload is written"
ensure_attr "$DR_ID" RecipeProcessedFlag --data-type Boolean --value false --description "Cleared by the caller, set true by the receiver when done"
ensure_attr "$DR_ID" RecipeProcessResult --data-type Boolean --value false --description "Outcome -> Result"
for s in DownloadPath WorkOrderNumber PartNumber JobStepNumber Username RecipeProcessResultText; do
ensure_attr "$DR_ID" "$s" --data-type String --description "Recipe-download payload / outcome text"
done
echo "3/7 Template TestMesMachine (standalone base) — compositions, knobs, alarm tags, alarms..."
M_ID=$(ensure_template TestMesMachine "In-memory MES/Delmia/alarm-API test machine: Left/Right MES receivers + Delmia receiver, simulated Galaxy side (docker/seed-mes-test-machine.sh)")
ensure_composition "$M_ID" "$RX_ID" LeftMESReceiver
ensure_composition "$M_ID" "$RX_ID" RightMESReceiver
ensure_composition "$M_ID" "$DR_ID" DelmiaReceiver
ensure_attr "$M_ID" SimNextBatchID --data-type Int32 --value 5000 --description "Next batch id the MoveIn simulator hands out (increments)"
ensure_attr "$M_ID" SimMoveInReject --data-type Boolean --value false --description "true = MoveIn simulator rejects every move-in"
ensure_attr "$M_ID" SimMoveOutReject --data-type Boolean --value false --description "true = MoveOut simulator rejects every move-out"
ensure_attr "$M_ID" SimRecipeReject --data-type Boolean --value false --description "true = Delmia simulator rejects every download"
ensure_attr "$M_ID" SimNoResponse --data-type Boolean --value false --description "true = simulators never answer (exercises the 25 s handshake timeout)"
ensure_attr "$M_ID" VacuumPressure --data-type Double --value 0.5 --description "Alarm source: HighVacuumFault (hi 5 / hihi 10)"
ensure_attr "$M_ID" ArgonPressure --data-type Double --value 50 --description "Alarm source: LowArgonPressure (lo 20 / lolo 10)"
ensure_attr "$M_ID" FurnaceTemp --data-type Double --value 800 --description "Alarm source: FurnaceOverTemp (hi 1050 / hihi 1100)"
ensure_attr "$M_ID" DoorOpen --data-type Boolean --value false --description "Alarm source: DoorOpenAlarm (true)"
ensure_attr "$M_ID" SensorStatus --data-type Int32 --value 0 --description "Alarm source: SensorFault (-1)"
ensure_alarm "$M_ID" HighVacuumFault --trigger-type HiLo --attribute VacuumPressure --hi 5 --hihi 10 --priority 800 --mes-reportable \
--description "Vacuum pressure high — MES-reportable"
ensure_alarm "$M_ID" LowArgonPressure --trigger-type HiLo --attribute ArgonPressure --lo 20 --lolo 10 --priority 600 --mes-reportable \
--description "Argon supply pressure low — MES-reportable"
ensure_alarm "$M_ID" FurnaceOverTemp --trigger-type HiLo --attribute FurnaceTemp --hi 1050 --hihi 1100 --priority 900 --mes-reportable \
--description "Furnace over temperature — MES-reportable"
ensure_alarm "$M_ID" DoorOpenAlarm --trigger-type ValueMatch --attribute DoorOpen --match-value true --priority 300 \
--description "Door open — NOT MES-reportable"
# --match-value=-1 (not a space) so the leading dash is not parsed as another option.
ensure_alarm "$M_ID" SensorFault --trigger-type ValueMatch --attribute SensorStatus --match-value=-1 --priority 700 \
--description "Sensor fault — not reportable on the template; SBTEST02 overrides it to reportable"
# ---------------------------------------------------------------------------------------------------
echo "4/7 Scripts on TestMesMachine..."
read -r -d '' MOVEIN_CODE <<'CS' || true
// TestMesMachine MesMoveIn — CvdReactor.MesMoveIn semantics on STATIC attributes.
// SAPID format: <SAPID>_<A|B>[_LT]; _A => LeftMESReceiver, _B => RightMESReceiver; _LT => MoveInType "LT".
System.Func<object?,bool> IsTrue = v => { var s=v?.ToString(); return (v is bool b && b)||s=="1"||string.Equals(s,"true",System.StringComparison.OrdinalIgnoreCase); };
System.Func<string,object?> P = k => Parameters.ContainsKey(k) ? Parameters[k] : null;
var sapid = P("SAPID")?.ToString() ?? "";
var isLeakTest = sapid.EndsWith("_LT");
var core = isLeakTest ? sapid.Substring(0, sapid.Length - 3) : sapid;
var side = core.EndsWith("_A") ? "Left" : core.EndsWith("_B") ? "Right" : null;
if (side == null)
return new { WasSuccessful=false, ErrorText="SAPID missing _A/_B side suffix", BatchID=0 };
var rx = Children[side + "MESReceiver"];
if (!IsTrue(await rx.Attributes.GetAsync("MoveInReadyFlag")))
return new { WasSuccessful=false, ErrorText="Machine move in ready flag not set to true", BatchID=0 };
try {
await rx.Attributes.SetAsync("MoveInCompleteFlag", false);
await rx.Attributes.SetAsync("MoveInType", isLeakTest ? "LT" : "Run");
await rx.Attributes.SetAsync("MoveInMesContainerNum", P("MoveInMesContainerNum"));
await rx.Attributes.SetAsync("MoveInJobSequenceNumber", P("MoveInJobSequenceNumber"));
await rx.Attributes.SetAsync("MoveInNumberWorkOrders", P("MoveInNumberWorkOrders"));
await rx.Attributes.SetAsync("MoveInWorkOrderNumbers", P("MoveInWorkOrderNumbers"));
await rx.Attributes.SetAsync("MoveInPartNumbers", P("MoveInPartNumbers"));
await rx.Attributes.SetAsync("MoveInOperatorName", P("MoveInOperatorName"));
await rx.Attributes.SetAsync("MoveInFlag", true);
if (!await rx.Attributes.WaitAsync("MoveInCompleteFlag", true, System.TimeSpan.FromSeconds(25)))
return new { WasSuccessful=false, ErrorText="Timeout waiting for move in information to be processed", BatchID=0 };
int batch=0; try { batch=System.Convert.ToInt32(await rx.Attributes.GetAsync("MoveInBatchID") ?? 0); } catch { }
return new {
WasSuccessful = IsTrue(await rx.Attributes.GetAsync("MoveInSuccessfulFlag")),
ErrorText = (await rx.Attributes.GetAsync("MoveInErrorText"))?.ToString() ?? "",
BatchID = batch
};
} catch (Exception ex) {
return new { WasSuccessful=false, ErrorText="MoveIn failed: " + ex.Message, BatchID=0 };
}
CS
read -r -d '' MOVEOUT_CODE <<'CS' || true
// TestMesMachine MesMoveOut — CvdReactor.MesMoveOut semantics on STATIC attributes. The "Data" rows are
// SIMULATED (CvdReactor reads dbo.MachineCycle / the LeakTest child; the test machine has neither).
System.Func<object?,bool> IsTrue = v => { var s=v?.ToString(); return (v is bool b && b)||s=="1"||string.Equals(s,"true",System.StringComparison.OrdinalIgnoreCase); };
System.Func<string,object?> P = k => Parameters.ContainsKey(k) ? Parameters[k] : null;
var none = new System.Collections.Generic.List<object>();
var sapid = P("SAPID")?.ToString() ?? "";
var isLeakTest = sapid.EndsWith("_LT");
var core = isLeakTest ? sapid.Substring(0, sapid.Length - 3) : sapid;
var side = core.EndsWith("_A") ? "Left" : core.EndsWith("_B") ? "Right" : null;
if (side == null)
return new { WasSuccessful=false, ErrorText="SAPID missing _A/_B side suffix", BatchID=0, Data=none };
var rx = Children[side + "MESReceiver"];
if (!IsTrue(await rx.Attributes.GetAsync("MoveOutReadyFlag")))
return new { WasSuccessful=false, ErrorText="Machine move out ready flag not set to true", BatchID=0, Data=none };
try {
await rx.Attributes.SetAsync("MoveOutCompleteFlag", false);
await rx.Attributes.SetAsync("MoveOutType", isLeakTest ? "LT" : "Run");
await rx.Attributes.SetAsync("MoveOutMesContainerNum", P("MoveOutMesContainerNum"));
await rx.Attributes.SetAsync("MoveOutNumberWorkOrders", P("MoveOutNumberWorkOrders"));
await rx.Attributes.SetAsync("MoveOutWorkOrderNumbers", P("MoveOutWorkOrderNumbers"));
await rx.Attributes.SetAsync("MoveOutPartNumbers", P("MoveOutPartNumbers"));
await rx.Attributes.SetAsync("MoveOutOperatorName", P("MoveOutOperatorName"));
await rx.Attributes.SetAsync("MoveOutFlag", true);
if (!await rx.Attributes.WaitAsync("MoveOutCompleteFlag", true, System.TimeSpan.FromSeconds(25)))
return new { WasSuccessful=false, ErrorText="Timeout waiting for move out information to be processed", BatchID=0, Data=none };
int batch=0; try { batch=System.Convert.ToInt32(await rx.Attributes.GetAsync("MoveOutBatchID") ?? 0); } catch { }
var ok = IsTrue(await rx.Attributes.GetAsync("MoveOutSuccessfulFlag"));
var err = (await rx.Attributes.GetAsync("MoveOutErrorText"))?.ToString() ?? "";
var data = new System.Collections.Generic.List<object>();
if (ok && batch != 0) {
var values = isLeakTest
? new (string, object)[] { ("LTType", "Rate of Rise"), ("LTDuration", 300), ("LTStart", 0.052), ("LTEnd", 0.061), ("LTDeltaVac", 0.009), ("LTResult", "Pass") }
: new (string, object)[] { ("TargetRuntime", 480), ("ReactorTempSP", 1050), ("PotTempSP", 980), ("AirSP", 0), ("ArgonSP", 40),
("Cl2SP", 12), ("H2SP", 25), ("VacSP", 0.5), ("ReactorTempAvg", 1047), ("PotTempAvg", 978),
("AirAvg", 0), ("ArAvg", 39), ("Cl2Avg", 12), ("H2Avg", 24), ("VacAvg", 0.51), ("RunDuration", 482) };
foreach (var (name, value) in values)
data.Add(new { BatchId = batch, CycleId = isLeakTest ? 0 : batch * 10 + 1, ValueName = name, Value = System.Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) });
}
return new { WasSuccessful = ok, ErrorText = err, BatchID = batch, Data = data };
} catch (Exception ex) {
return new { WasSuccessful=false, ErrorText="MoveOut failed: " + ex.Message, BatchID=0, Data=none };
}
CS
read -r -d '' RECIPE_CODE <<'CS' || true
// TestMesMachine ProcessRecipeDownload — CvdReactor.ProcessRecipeDownload semantics on STATIC attributes
// of the composed DelmiaReceiver (same flags, same 25 s budget, same result mapping).
System.Func<object?,bool> IsTrue = v => { var s=v?.ToString(); return (v is bool b && b)||s=="1"||string.Equals(s,"true",System.StringComparison.OrdinalIgnoreCase); };
System.Func<string,object?> P = k => Parameters.ContainsKey(k) ? Parameters[k] : null;
var recv = Children["DelmiaReceiver"];
if (!IsTrue(await recv.Attributes.GetAsync("ReadyFlag")))
return new { Result = false, ResultText = "Receiver ready flag not set to true" };
try {
await recv.Attributes.SetAsync("RecipeProcessedFlag", false);
await recv.Attributes.SetAsync("DownloadPath", P("DownloadPath"));
await recv.Attributes.SetAsync("WorkOrderNumber", P("WorkOrderNumber"));
await recv.Attributes.SetAsync("PartNumber", P("PartNumber"));
await recv.Attributes.SetAsync("JobStepNumber", P("JobStepNumber"));
await recv.Attributes.SetAsync("Username", P("Username"));
await recv.Attributes.SetAsync("RecipeDownloadFlag", true);
if (!await recv.Attributes.WaitAsync("RecipeProcessedFlag", true, System.TimeSpan.FromSeconds(25)))
return new { Result = false, ResultText = "Timeout waiting for recipe to be processed" };
return new {
Result = IsTrue(await recv.Attributes.GetAsync("RecipeProcessResult")),
ResultText = (await recv.Attributes.GetAsync("RecipeProcessResultText"))?.ToString() ?? ""
};
} catch (Exception ex) {
return new { Result = false, ResultText = "RecipeDownload failed: " + ex.Message };
}
CS
# The MES alarm-status projection (same as docker/seed-mes-alarm-demo.sh). Computed alarms report
# Acknowledged=true / AckTime=null by construction, so StatusCode reads "Triggered.Acked" and
# IncludeAcked=false drops every computed alarm (it only bites on mirrored native conditions).
read -r -d '' ALARM_CODE <<'CS' || true
var code = Parameters["MachineCode"]?.ToString() ?? "";
var flaggedOnly = Parameters.Get<bool?>("FlaggedOnly") ?? false;
var includeAck = Parameters.Get<bool?>("IncludeAcked") ?? true;
var minSev = Parameters.Get<int?>("MinSeverity");
var maxSev = Parameters.Get<int?>("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.Message : !string.IsNullOrEmpty(a.AlarmTypeName) ? a.AlarmTypeName : a.Name,
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<object>() };
}
CS
MOVEIN_PARAMS='{"type":"object","properties":{"SAPID":{"type":"string"},"MoveInMesContainerNum":{"type":"string"},"MoveInJobSequenceNumber":{"type":"string"},"MoveInNumberWorkOrders":{"type":"integer"},"MoveInWorkOrderNumbers":{"type":"array","items":{"type":"string"}},"MoveInPartNumbers":{"type":"array","items":{"type":"string"}},"MoveInOperatorName":{"type":"string"}},"required":[]}'
MOVEOUT_PARAMS='{"type":"object","properties":{"SAPID":{"type":"string"},"MoveOutMesContainerNum":{"type":"string"},"MoveOutNumberWorkOrders":{"type":"integer"},"MoveOutWorkOrderNumbers":{"type":"array","items":{"type":"string"}},"MoveOutPartNumbers":{"type":"array","items":{"type":"string"}},"MoveOutOperatorName":{"type":"string"}},"required":[]}'
RECIPE_PARAMS='{"type":"object","properties":{"DownloadPath":{"type":"string"},"WorkOrderNumber":{"type":"string"},"PartNumber":{"type":"string"},"JobStepNumber":{"type":"string"},"Username":{"type":"string"}},"required":[]}'
ALARM_PARAMS='{"type":"object","properties":{"MachineCode":{"type":"string"},"FlaggedOnly":{"type":"boolean"},"IncludeAcked":{"type":"boolean"},"MinSeverity":{"type":"integer"},"MaxSeverity":{"type":"integer"},"NameFilter":{"type":"string"}},"required":["MachineCode"]}'
ALARM_RETURN='{"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"]}'
upsert_script "$M_ID" MesMoveIn --trigger-type Call --parameters "$MOVEIN_PARAMS" --code "$MOVEIN_CODE"
upsert_script "$M_ID" MesMoveOut --trigger-type Call --parameters "$MOVEOUT_PARAMS" --code "$MOVEOUT_CODE"
upsert_script "$M_ID" ProcessRecipeDownload --trigger-type Call --parameters "$RECIPE_PARAMS" \
--return-def '{"type":"object","properties":{"Result":{"type":"boolean"},"ResultText":{"type":"string"}},"required":["Result","ResultText"]}' \
--code "$RECIPE_CODE"
upsert_script "$M_ID" AlarmStatus --trigger-type Call --parameters "$ALARM_PARAMS" --return-def "$ALARM_RETURN" --code "$ALARM_CODE"
# --- Galaxy-side simulators (ValueChange on each trigger flag; act on the rising edge only) ----------
sim_movein_code() { # $1 = Left|Right
cat <<CS
// Plays the Galaxy side of the $1 MESReceiver MoveIn: rejects when SimMoveInReject is set or a work
// order contains "FAIL"; otherwise hands out SimNextBatchID (and increments it). SimNoResponse = never answer.
System.Func<object?,bool> IsTrue = v => { var s=v?.ToString(); return (v is bool b && b)||s=="1"||string.Equals(s,"true",System.StringComparison.OrdinalIgnoreCase); };
var rx = Children["$1MESReceiver"];
if (!IsTrue(await rx.Attributes.GetAsync("MoveInFlag"))) return null;
if (IsTrue(await Attributes.GetAsync("SimNoResponse"))) return null;
var wos = (await rx.Attributes.GetAsync("MoveInWorkOrderNumbers")) as System.Collections.IEnumerable;
var hasFail = wos != null && !(wos is string) && wos.Cast<object?>().Any(w => (w?.ToString() ?? "").Contains("FAIL", StringComparison.OrdinalIgnoreCase));
var forced = IsTrue(await Attributes.GetAsync("SimMoveInReject"));
var ok = !forced && !hasFail;
var batch = 0;
if (ok) {
batch = System.Convert.ToInt32(await Attributes.GetAsync("SimNextBatchID") ?? 5000);
await Attributes.SetAsync("SimNextBatchID", batch + 1);
}
await rx.Attributes.SetAsync("MoveInBatchID", batch);
await rx.Attributes.SetAsync("MoveInSuccessfulFlag", ok);
await rx.Attributes.SetAsync("MoveInErrorText", ok ? "" : forced ? "Simulated move in reject (SimMoveInReject)" : "Simulated move in reject: work order contains FAIL");
await rx.Attributes.SetAsync("MoveInFlag", false);
await rx.Attributes.SetAsync("MoveInCompleteFlag", true);
return null;
CS
}
sim_moveout_code() { # $1 = Left|Right
cat <<CS
// Plays the Galaxy side of the $1 MESReceiver MoveOut: moves out the batch last moved in on this side
// (MoveInBatchID, cleared on success); rejects when nothing is moved in, SimMoveOutReject is set or a
// work order contains "FAIL". SimNoResponse = never answer.
System.Func<object?,bool> IsTrue = v => { var s=v?.ToString(); return (v is bool b && b)||s=="1"||string.Equals(s,"true",System.StringComparison.OrdinalIgnoreCase); };
var rx = Children["$1MESReceiver"];
if (!IsTrue(await rx.Attributes.GetAsync("MoveOutFlag"))) return null;
if (IsTrue(await Attributes.GetAsync("SimNoResponse"))) return null;
var wos = (await rx.Attributes.GetAsync("MoveOutWorkOrderNumbers")) as System.Collections.IEnumerable;
var hasFail = wos != null && !(wos is string) && wos.Cast<object?>().Any(w => (w?.ToString() ?? "").Contains("FAIL", StringComparison.OrdinalIgnoreCase));
var forced = IsTrue(await Attributes.GetAsync("SimMoveOutReject"));
var batch = 0; try { batch = System.Convert.ToInt32(await rx.Attributes.GetAsync("MoveInBatchID") ?? 0); } catch { }
var err = forced ? "Simulated move out reject (SimMoveOutReject)"
: hasFail ? "Simulated move out reject: work order contains FAIL"
: batch == 0 ? "No batch is moved in on the $1 side"
: "";
var ok = err.Length == 0;
await rx.Attributes.SetAsync("MoveOutBatchID", ok ? batch : 0);
await rx.Attributes.SetAsync("MoveOutSuccessfulFlag", ok);
await rx.Attributes.SetAsync("MoveOutErrorText", err);
if (ok) await rx.Attributes.SetAsync("MoveInBatchID", 0);
await rx.Attributes.SetAsync("MoveOutFlag", false);
await rx.Attributes.SetAsync("MoveOutCompleteFlag", true);
return null;
CS
}
read -r -d '' SIM_RECIPE_CODE <<'CS' || true
// Plays the Galaxy DelmiaReceiver: accepts unless SimRecipeReject is set, DownloadPath is blank or ends in
// ".bad", or the work order is blank. SimNoResponse = never answer.
System.Func<object?,bool> IsTrue = v => { var s=v?.ToString(); return (v is bool b && b)||s=="1"||string.Equals(s,"true",System.StringComparison.OrdinalIgnoreCase); };
var recv = Children["DelmiaReceiver"];
if (!IsTrue(await recv.Attributes.GetAsync("RecipeDownloadFlag"))) return null;
if (IsTrue(await Attributes.GetAsync("SimNoResponse"))) return null;
var path = (await recv.Attributes.GetAsync("DownloadPath"))?.ToString() ?? "";
var wo = (await recv.Attributes.GetAsync("WorkOrderNumber"))?.ToString() ?? "";
var forced = IsTrue(await Attributes.GetAsync("SimRecipeReject"));
var ok = !forced && path.Length > 0 && wo.Length > 0 && !path.EndsWith(".bad", System.StringComparison.OrdinalIgnoreCase);
await recv.Attributes.SetAsync("RecipeProcessResult", ok);
await recv.Attributes.SetAsync("RecipeProcessResultText", ok ? "Simulated download accepted for " + wo
: forced ? "Simulated reject (SimRecipeReject)" : "Simulated reject: bad path or missing work order");
await recv.Attributes.SetAsync("RecipeDownloadFlag", false);
await recv.Attributes.SetAsync("RecipeProcessedFlag", true);
return null;
CS
for side in Left Right; do
upsert_script "$M_ID" "Sim${side}MoveIn" --trigger-type ValueChange --trigger-config "{\"attributeName\":\"${side}MESReceiver.MoveInFlag\"}" --code "$(sim_movein_code $side)"
upsert_script "$M_ID" "Sim${side}MoveOut" --trigger-type ValueChange --trigger-config "{\"attributeName\":\"${side}MESReceiver.MoveOutFlag\"}" --code "$(sim_moveout_code $side)"
done
upsert_script "$M_ID" SimDelmiaRecipe --trigger-type ValueChange --trigger-config '{"attributeName":"DelmiaReceiver.RecipeDownloadFlag"}' --code "$SIM_RECIPE_CODE"
# ---------------------------------------------------------------------------------------------------
echo "5/7 Instances ${INSTANCES[*]} on site $SITE_ID..."
INST_IDS=()
for name in "${INSTANCES[@]}"; do
id=$("$CLI" instance list "${A[@]}" | find_id_opt uniqueName "$name")
if [ -z "$id" ]; then
id=$("$CLI" instance create "${A[@]}" --name "$name" --template-id "$M_ID" --site-id "$SITE_ID" | json_id)
echo " created $name id=$id"
else
echo " $name exists id=$id"
fi
INST_IDS+=("$id")
done
# INSTANCES[1] = SBTEST02 (bash 3.2 on macOS has no associative arrays)
"$CLI" instance alarm-override set "${A[@]}" --instance-id "${INST_IDS[1]}" --alarm SensorFault --mes-reportable true > /dev/null
echo " ${INSTANCES[1]}: SensorFault overridden to MES-reportable"
echo "6/7 Deploying..."
for i in "${!INSTANCES[@]}"; do
"$CLI" instance deploy "${A[@]}" --id "${INST_IDS[$i]}" > /dev/null
echo " deployed ${INSTANCES[$i]}"
done
# ---------------------------------------------------------------------------------------------------
echo "7/7 Inbound alarm methods SimpleAlarmStatusRequest / AlarmStatusRequest (BTDB machine lookup)..."
# Shared: legacy-compatible machine resolution. SAPID matches the BTDB value exactly first, then by its
# leading digits (the MesMoveIn convention, so "990001_A" works too). The instance is the BTDB Code with a
# trailing A/B side letter stripped (the MesMoveOut convention); HierarchicalName keeps the BTDB Code.
read -r -d '' SIMPLE_METHOD_CODE <<'CS' || true
System.Func<string, object?> P = k => Parameters.ContainsKey(k) ? Parameters[k] : null;
var raw = (P("SAPID")?.ToString() ?? "").Trim();
var none = Array.Empty<object>();
try {
string? code = null;
if (raw.Length > 0) {
code = await Database.QuerySingleAsync<string>("BTDB", "SELECT TOP 1 Code FROM dbo.Machine WHERE SAPID=@s", new { s = raw });
var m = System.Text.RegularExpressions.Regex.Match(raw, @"^(\d+)");
if (string.IsNullOrEmpty(code) && m.Success && m.Groups[1].Value != raw)
code = await Database.QuerySingleAsync<string>("BTDB", "SELECT TOP 1 Code FROM dbo.Machine WHERE SAPID=@s", new { s = m.Groups[1].Value });
}
if (string.IsNullOrEmpty(code))
return new { WasSuccessful = false, ErrorText = $"Failed to find machine with SAPID '{raw}'", Alarms = none };
var instance = (code.EndsWith("A") || code.EndsWith("B")) ? code.Substring(0, code.Length - 1) : code;
try {
return await Route.To(instance).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 = none };
}
} catch (Exception ex) {
return new { WasSuccessful = false, ErrorText = "Failed to read machine alarm status: " + ex.Message, Alarms = none };
}
CS
read -r -d '' FULL_METHOD_CODE <<'CS' || true
// Legacy POST /mes/alarmstatus: MachineFilter precedence SAPID -> Code -> ZTag -> MachineID (first
// non-empty wins, each resolved through BTDB.dbo.Machine), then AlarmFilter applied by the site script.
// AlarmFilter.IncludeTriggered is accepted and ignored, exactly like the legacy service.
var none = Array.Empty<object>();
var mf = Parameters.ContainsKey("MachineFilter") ? Parameters["MachineFilter"] as System.Collections.Generic.IDictionary<string, object?> : null;
var af = Parameters.ContainsKey("AlarmFilter") ? Parameters["AlarmFilter"] as System.Collections.Generic.IDictionary<string, object?> : null;
object? M(string k) => mf != null && mf.TryGetValue(k, out var v) ? v : null;
object? F(string k) => af != null && af.TryGetValue(k, out var v) ? v : null;
string? S(object? o) { var s = o?.ToString()?.Trim(); return string.IsNullOrEmpty(s) ? null : s; }
try {
string? code = null, error = null;
if (S(M("SAPID")) is string sap) {
code = await Database.QuerySingleAsync<string>("BTDB", "SELECT TOP 1 Code FROM dbo.Machine WHERE SAPID=@s", new { s = sap });
var m = System.Text.RegularExpressions.Regex.Match(sap, @"^(\d+)");
if (string.IsNullOrEmpty(code) && m.Success && m.Groups[1].Value != sap)
code = await Database.QuerySingleAsync<string>("BTDB", "SELECT TOP 1 Code FROM dbo.Machine WHERE SAPID=@s", new { s = m.Groups[1].Value });
if (string.IsNullOrEmpty(code)) error = $"Failed to find machine with SAPID '{sap}'";
} else if (S(M("Code")) is string c) {
code = await Database.QuerySingleAsync<string>("BTDB", "SELECT TOP 1 Code FROM dbo.Machine WHERE Code=@c", new { c });
if (string.IsNullOrEmpty(code)) error = $"Failed to find machine with Code '{c}'";
} else if (S(M("ZTag")) is string z) {
code = await Database.QuerySingleAsync<string>("BTDB", "SELECT TOP 1 Code FROM dbo.Machine WHERE ZTag=@z", new { z });
if (string.IsNullOrEmpty(code)) error = $"Failed to find machine with ZTag '{z}'";
} else if (S(M("MachineID")) is string idText && long.TryParse(idText, out var id)) {
code = await Database.QuerySingleAsync<string>("BTDB", "SELECT TOP 1 Code FROM dbo.Machine WHERE MachineID=@id", new { id });
if (string.IsNullOrEmpty(code)) error = $"Failed to find machine with MachineID '{id}'";
} else {
error = "Failed to find machine with given machine filter (supply SAPID, Code, ZTag or MachineID)";
}
if (error != null || code == null)
return new { WasSuccessful = false, ErrorText = error, Alarms = none };
var instance = (code.EndsWith("A") || code.EndsWith("B")) ? code.Substring(0, code.Length - 1) : code;
int? I(object? o) => o == null ? null : int.TryParse(o.ToString(), out var n) ? n : null;
try {
return await Route.To(instance).Call("AlarmStatus", new {
MachineCode = code,
FlaggedOnly = F("FlaggedOnly") is bool fo && fo,
IncludeAcked = !(F("IncludeAcked") is bool ia) || ia,
MinSeverity = I(F("MinSeverity")),
MaxSeverity = I(F("MaxSeverity")),
NameFilter = S(F("NameFilter")) });
} 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 = none };
}
} catch (Exception ex) {
return new { WasSuccessful = false, ErrorText = "Failed to read machine alarm status: " + ex.Message, Alarms = none };
}
CS
SIMPLE_PARAMS='{"type":"object","properties":{"SAPID":{"type":"string"}}}'
FULL_PARAMS='{"type":"object","properties":{"MachineFilter":{"type":"object","properties":{"MachineID":{"type":"integer"},"SAPID":{"type":"string"},"ZTag":{"type":"string"},"Code":{"type":"string"}}},"AlarmFilter":{"type":"object","properties":{"NameFilter":{"type":"string"},"MinSeverity":{"type":"integer"},"MaxSeverity":{"type":"integer"},"IncludeTriggered":{"type":"boolean"},"IncludeAcked":{"type":"boolean"},"FlaggedOnly":{"type":"boolean"}}}}}'
upsert_method() { # $1 name, $2 params, $3 script
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 — smoke it afterwards.
"$CLI" api-method update "${A[@]}" --id "$existing" --timeout 30 --parameters "$2" --return-def "$ALARM_RETURN" --script "$3" > /dev/null
echo " updated $1 id=$existing"
else
existing=$("$CLI" api-method create "${A[@]}" --name "$1" --timeout 30 --parameters "$2" --return-def "$ALARM_RETURN" --script "$3" | json_id)
echo " created $1 id=$existing"
fi
}
upsert_method SimpleAlarmStatusRequest "$SIMPLE_PARAMS" "$SIMPLE_METHOD_CODE"
upsert_method AlarmStatusRequest "$FULL_PARAMS" "$FULL_METHOD_CODE"
echo ""
echo "TEST_MES_RECEIVER_ID=$RX_ID TEST_DELMIA_RECEIVER_ID=$DR_ID TEST_MES_MACHINE_ID=$M_ID"
for i in "${!INSTANCES[@]}"; do echo "INSTANCE ${INSTANCES[$i]} id=${INST_IDS[$i]}"; done
+125
View File
@@ -0,0 +1,125 @@
#!/bin/bash
set -euo pipefail
# Live gate for docker/seed-mes-test-machine.sh — drives the REAL inbound methods (MesMoveIn, MesMoveOut,
# DelmiaRecipeDownload, SimpleAlarmStatusRequest, AlarmStatusRequest) through `flow-debug run-method`
# (the same inbound executor an HTTP call reaches, minus the API-key check — no key needed) against the
# SBTEST01 / SBTEST02 test machines, and trips/clears the computed alarms by writing their static source
# attributes. Needs the BTDB rows SBTEST01=990001 / SBTEST02=990002 (vd03 only).
#
# SCADABRIDGE_URL=http://wonder-app-vd03.zmr.zimmer.com:8085 bash docker/verify-mes-test-machine.sh
#
# Leaves both machines as it found them: alarm sources reset to their defaults, no batch moved in.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
export SB_CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
export SB_URL="${SCADABRIDGE_URL:-http://localhost:9000}"
export SB_USER="${SCADABRIDGE_USERNAME:-multi-role}" SB_PASS="${SCADABRIDGE_PASSWORD:-password}"
python3 - <<'PY'
import json, os, subprocess, sys
CLI, URL = os.environ["SB_CLI"], os.environ["SB_URL"]
AUTH = ["--url", URL, "--format=json", "--username", os.environ["SB_USER"], "--password", os.environ["SB_PASS"]]
def cli(*args, timeout=180):
r = subprocess.run([CLI, *args, *AUTH], capture_output=True, text=True, timeout=timeout)
if r.returncode != 0:
raise RuntimeError(f"{' '.join(args[:3])}: {r.stderr.strip() or r.stdout.strip()}")
return json.loads(r.stdout)
methods = {m["name"]: m["id"] for m in cli("api-method", "list")}
instances = {i["uniqueName"]: i["id"] for i in cli("instance", "list")}
def call(method, params):
"""Run an inbound method via the Flow Debugger and return its response body (dict)."""
d = cli("flow-debug", "run-method", "--method-id", str(methods[method]), "--params", json.dumps(params),
"--timeout-seconds", "90", timeout=150)
ev = next(e for e in d["events"] if e["action"] == "ApiInbound.InboundRequest")
det = json.loads(ev["detailsJson"])
if det.get("status") != "Delivered":
raise RuntimeError(f"{method} not delivered: {det.get('status')} {det.get('errorMessage')}")
return json.loads(det["responseSummary"])
def write(inst, attr, value):
subprocess.run([CLI, "flow-debug", "run-value-change", "--instance-id", str(instances[inst]), "--attribute", attr,
"--value", str(value), "--wait-seconds", "1", *AUTH], capture_output=True, text=True, timeout=90)
passed = failed = 0
def check(label, cond, detail=""):
global passed, failed
if cond: passed += 1; print(f" PASS {label}")
else: failed += 1; print(f" FAIL {label} {detail}")
WO = [{"WorkOrderNumber": "WO-1001", "PartNumber": "PN-2001"}]
def movein(sap, wo=WO): return call("MesMoveIn", {"SAPID": sap, "ContainerNumber": "CN-1", "JobSequenceNumber": "10", "NumberWorkOrders": len(wo), "WorkOrders": wo, "OperatorName": "sb-test"})
def moveout(sap, wo=WO): return call("MesMoveOut", {"SAPID": sap, "ContainerNumber": "CN-1", "NumberWorkOrders": len(wo), "WorkOrders": wo, "OperatorName": "sb-test"})
def names(r): return sorted(a["Name"] for a in r.get("Alarms") or [])
print("A. DelmiaRecipeDownload (routes by MachineCode)")
r = call("DelmiaRecipeDownload", {"MachineCode": "SBTEST01", "DownloadPath": r"\\share\recipes\r1.xml", "WorkOrderNumber": "WO-1001", "PartNumber": "PN-2001", "JobStepNumber": "20", "Username": "sb-test"})
check("A1 accepted", r.get("Result") is True and "accepted" in r.get("ResultText", ""), r)
r = call("DelmiaRecipeDownload", {"MachineCode": "SBTEST01", "DownloadPath": r"\\share\recipes\r1.bad", "WorkOrderNumber": "WO-1001"})
check("A2 .bad path rejected", r.get("Result") is False and "reject" in r.get("ResultText", "").lower(), r)
print("B. MesMoveIn / MesMoveOut (SAPID -> BTDB -> SBTEST01)")
r = movein("990001_A")
batch = r.get("BatchID", 0)
check("B1 MoveIn left succeeds with a batch id", r.get("WasSuccessful") is True and batch >= 5000, r)
r = moveout("990001_A")
check("B2 MoveOut left returns the same batch", r.get("WasSuccessful") is True and r.get("BatchID") == batch, r)
check("B3 MoveOut Run returns 16 cycle rows", len(r.get("Data") or []) == 16 and r["Data"][0].get("ValueName") == "TargetRuntime", r.get("Data"))
r = moveout("990001_A")
check("B4 second MoveOut rejected (nothing moved in)", r.get("WasSuccessful") is False and "No batch" in r.get("ErrorText", ""), r)
r = movein("990001_A", [{"WorkOrderNumber": "WO-FAIL-1", "PartNumber": "PN-2001"}])
check("B5 FAIL work order rejected", r.get("WasSuccessful") is False and "FAIL" in r.get("ErrorText", ""), r)
r = movein("990001")
check("B6 missing side suffix", r.get("WasSuccessful") is False and "suffix" in r.get("ErrorText", ""), r)
r = movein("990001_B_LT"); lt = r.get("BatchID", 0)
check("B7 leak-test MoveIn right", r.get("WasSuccessful") is True and lt > 0, r)
r = moveout("990001_B_LT")
check("B8 leak-test MoveOut returns 6 LT rows", r.get("WasSuccessful") is True and len(r.get("Data") or []) == 6 and r["Data"][0]["ValueName"] == "LTType", r)
r = movein("999999_A")
check("B9 unknown SAPID", r.get("WasSuccessful") is False and "No machine found" in r.get("ErrorText", ""), r)
print("C. Alarm APIs")
r = call("SimpleAlarmStatusRequest", {"SAPID": "990001"})
check("C1 no alarms active -> success, empty", r.get("WasSuccessful") is True and names(r) == [], r)
for inst in ("SBTEST01", "SBTEST02"):
write(inst, "VacuumPressure", 12); write(inst, "DoorOpen", "true"); write(inst, "SensorStatus", -1)
r = call("SimpleAlarmStatusRequest", {"SAPID": "990001"})
check("C2 SBTEST01 simple = flagged only (HighVacuumFault)", names(r) == ["HighVacuumFault"], names(r))
a = (r.get("Alarms") or [{}])[0]
check("C3 row shape", a.get("HierarchicalName") == "SBTEST01.HighVacuumFault" and a.get("IsFlaggedForMES") is True
and a.get("Severity") == 800 and a.get("StatusCode") == "Triggered.Acked" and a.get("TriggeredDT"), a)
r = call("SimpleAlarmStatusRequest", {"SAPID": "990002"})
check("C4 SBTEST02 simple includes the overridden SensorFault", names(r) == ["HighVacuumFault", "SensorFault"], names(r))
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST01"}})
check("C5 full, no filter = all 3 active", names(r) == ["DoorOpenAlarm", "HighVacuumFault", "SensorFault"], names(r))
flags = {x["Name"]: x["IsFlaggedForMES"] for x in r.get("Alarms") or []}
check("C6 IsFlaggedForMES per alarm", flags == {"DoorOpenAlarm": False, "HighVacuumFault": True, "SensorFault": False}, flags)
r = call("AlarmStatusRequest", {"MachineFilter": {"SAPID": "990001_A"}, "AlarmFilter": {"MinSeverity": 500}})
check("C7 SAPID w/ suffix + MinSeverity 500", names(r) == ["HighVacuumFault", "SensorFault"], names(r))
r = call("AlarmStatusRequest", {"MachineFilter": {"ZTag": "SBTEST01"}, "AlarmFilter": {"NameFilter": "door"}})
check("C8 ZTag + NameFilter", names(r) == ["DoorOpenAlarm"], names(r))
r = call("AlarmStatusRequest", {"MachineFilter": {"MachineID": 204}, "AlarmFilter": {"FlaggedOnly": True, "MaxSeverity": 750}})
check("C9 MachineID + FlaggedOnly + MaxSeverity", names(r) == ["SensorFault"], names(r))
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST01"}, "AlarmFilter": {"IncludeAcked": False}})
check("C10 IncludeAcked=false drops computed (always-acked) alarms", r.get("WasSuccessful") is True and names(r) == [], names(r))
r = call("AlarmStatusRequest", {"MachineFilter": {"SAPID": "123"}})
check("C11 unknown SAPID error text", r.get("WasSuccessful") is False and "Failed to find machine with SAPID '123'" in (r.get("ErrorText") or ""), r)
r = call("AlarmStatusRequest", {})
check("C12 empty filter error", r.get("WasSuccessful") is False and "machine filter" in (r.get("ErrorText") or ""), r)
r = call("SimpleAlarmStatusRequest", {"SAPID": "103559"})
check("C13 machine without AlarmStatus script -> not supported", r.get("WasSuccessful") is False and "not supported" in (r.get("ErrorText") or ""), r)
print("D. Reset alarm sources")
for inst in ("SBTEST01", "SBTEST02"):
write(inst, "VacuumPressure", 0.5); write(inst, "DoorOpen", "false"); write(inst, "SensorStatus", 0)
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST02"}})
check("D1 cleared", r.get("WasSuccessful") is True and names(r) == [], names(r))
print(f"\n{'PASS' if failed == 0 else 'FAIL'} {passed}/{passed + failed}")
sys.exit(1 if failed else 0)
PY
@@ -0,0 +1,126 @@
# 2026-09-28 — DEV (wonder-app-vd03) → `fe89363b` + in-memory MES/Delmia/alarm-API test machines
**Build:** `main` @ `fe89363b` (= `origin/main`; code tip `3a21cfc2`), ProductVersion
`1.0.0+fe89363b1a2dbc07c7578cd33c88b7162a1b19e7`, published from a detached worktree at that SHA
(`dotnet publish` Host + CLI `-c Release -r win-x64 --self-contained true -p:PublishSingleFile=false`;
1029 app files, 223 cli files, zip 125 593 314 B, SHA-256 `bb821df1…87167033`).
**Delta from the box's previous build `2b9a7c3c`:** 62 commits — MES-reportable computed-alarm flag
(`IsMesReportable`, `Alarms.MesReportableAsync()`), site artifact revision + pending-updates indicator,
Gitea #36 reset-aware handshake overload, Gitea #37 Flow Debugger structured method parameters.
**QA (vq03/vq04) NOT touched** — still `2b9a7c3c` at migration 55 (register row 126).
**Transport:** Mac-side `winrm` CLI — WinRM 5985/NTLM for the two on-box scripts, `winrm smb push`
over 445 for the zip/SQL/scripts. First deploy under the 2026-09-28 *deployment-only* WinRM scope
(CLAUDE.md); every non-deploy read (baseline, logs, verification) went over HTTP or SMB.
## Schema: 55 → 57 migrations (additive only)
| Migration | Effect |
|---|---|
| `20260911135501_AddAlarmMesReportable` | `TemplateAlarms.IsMesReportable bit NOT NULL DEFAULT 0`; `InstanceAlarmOverrides.IsMesReportableOverride bit NULL` |
| `20260914173147_AddSiteArtifactRevision` | `Sites.ArtifactRevisionHash nvarchar(100) NULL`; `Sites.ArtifactsDeployedAt datetimeoffset NULL` |
From→to script `dotnet ef migrations script 20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration
20260914173147_AddSiteArtifactRevision` (`sb-mig-55to57.sql`, NOT idempotent — reusable as-is for the QA
pair), applied with `sqlcmd -C -b -I` while both services were stopped. No data upgrade; the Central boot
logged no "Applying migration" (the script had already applied both).
## Config
**No appsettings change.** The only new option in the delta, `ScadaBridge:DeploymentManager:PendingUpdatesCacheTtl`,
defaults to 60 s. The box's three `appsettings*.json` were copied out, restored after the swap and SHA-256-matched
(`A6B6CF05…` / `3382DD83…` / `1A4333DA…`). Service registry environment untouched.
## Procedure (`E:\psrem-stage\fe89363b\`, log `E:\ApiInstall\ScadaBridge\_DEPLOY-NOTES-20260928T095846.txt`)
1. Baseline over HTTP from the Mac: `/health/ready` 200/200; `debug snapshot` Z28061(1) Bad 126 / Good 144,
Z28062(2) Bad 124 / Good 146, Z28061Sim(8) Bad 28 / Good 242 (270 attrs, 7 alarms each).
2. `sb-prep.ps1` (09:58:25): zip SHA-256 re-verified on the box, unpacked, staged Host/CLI ProductVersion =
`fe89363b`; `reg export` of both service keys; `BACKUP DATABASE [ScadaBridge] … COPY_ONLY, CHECKSUM,
COMPRESSION` → `E:\ApiInstall\ScadaBridge-backups\ScadaBridge_pre-fe89363b_20260928T095825.bak`
(140 795 392 B), `RESTORE VERIFYONLY … WITH CHECKSUM` valid; DB at 55 migrations.
3. `sb-swap.ps1` (the 2026-09-10 script with the SHA and 55→57 step swapped in): **09:58:46** Site stopped
(10 s), Central stopped (10 s), no `Host.exe` left; 09:59:08 migrations 55 → 57; `app`/`cli` →
`*.bak.20260928T095846`, staged copy in, appsettings restored (SHA match); 09:59:14 Central started →
ready 09:59:30; Site started → ready **09:59:39**. **~53 s total downtime.**
4. `deploy artifacts` (last step per register row 126): `Dev Site` success; the site now reports
`artifactRevisionHash sha256:9e054ef0…`, `artifactsDeployedAt 2026-09-28T14:01:04Z`.
## Verification
| Check | Result |
|---|---|
| `:8085` / `:8084 /health/ready` | 200 Healthy / 200 Healthy |
| ProductVersion | live Host + CLI `fe89363b` |
| DB | 57 migrations |
| Snapshots after (settled) | Z28061 Bad 126 / Good 144, Z28062 Bad 124 / Good 146, Z28061Sim Bad 28 / Good 242 — **identical to baseline** (the first read ~20 s after start showed 20 tags Uncertain while subscriptions settled) |
| Logs after boot (pulled over SMB) | central + site: only the usual first-probe `akka-cluster … member status: Removed` ERR lines during startup; no other ERR/FTL |
## In-memory MES / Delmia / alarm-API test machines (same session)
Seeded with **`docker/seed-mes-test-machine.sh`** (idempotent; second run reused every id and the gate
re-passed) and gated with **`docker/verify-mes-test-machine.sh` — PASS 25/25**. Nothing existing was
modified except the `SimpleAlarmStatusRequest` stub (below); CvdReactor and its instances are untouched.
| Object | Id | Notes |
|---|---|---|
| Template `TestMESReceiver` | 28 | 26 STATIC attributes with the prod MESReceiver names; ready flags default true |
| Template `TestDelmiaReceiver` | 29 | 10 STATIC attributes with the prod DelmiaReceiver names; ReadyFlag true |
| Template `TestMesMachine` | 30 | standalone base (not CvdReactor-derived); composes `LeftMESReceiver`/`RightMESReceiver`/`DelmiaReceiver`; Call scripts `MesMoveIn`, `MesMoveOut`, `ProcessRecipeDownload`, `AlarmStatus`; simulators `SimLeft/RightMoveIn`, `SimLeft/RightMoveOut`, `SimDelmiaRecipe` |
| Instance `SBTEST01` | 9 | Dev Site, deployed |
| Instance `SBTEST02` | 10 | Dev Site, deployed; alarm override `SensorFault` → MES-reportable |
| Inbound `SimpleAlarmStatusRequest` | 9 | **was a stub returning `[]`** — now real: SAPID → BTDB → `AlarmStatus`, flagged-only, acked included |
| Inbound `AlarmStatusRequest` | 12 | new: legacy `/mes/alarmstatus` shape — `MachineFilter` (SAPID → Code → ZTag → MachineID, via BTDB) + `AlarmFilter` |
| BTDB `dbo.Machine` | 203, 204 | `SBTEST01` SAPID `990001`, `SBTEST02` SAPID `990002` (Code = Name = ZTag = instance name, `Eastern Standard Time`, `LastUpdateBy = scadabridge-test-seed`), inserted through a temporary inbound method (`zzTmpBtdbSchema`, id 11) that was deleted afterwards |
**Alarms** (static source → computed alarm): `VacuumPressure` → `HighVacuumFault` (HiLo hi 5 / hihi 10, 800,
MES), `ArgonPressure` → `LowArgonPressure` (lo 20 / lolo 10, 600, MES), `FurnaceTemp` → `FurnaceOverTemp`
(hi 1050 / hihi 1100, 900, MES), `DoorOpen` → `DoorOpenAlarm` (true, 300, not MES), `SensorStatus` →
`SensorFault` (−1, 700, not MES on the template; MES on SBTEST02 via override).
**Simulator knobs** (root static attributes): `SimNextBatchID` (5000, increments per move-in),
`SimMoveInReject`, `SimMoveOutReject`, `SimRecipeReject`, `SimNoResponse` (never answer → the 25 s timeout
path). Input-driven rejects: a work order containing `FAIL`; a Delmia path ending `.bad` or a blank work
order; a move-out with nothing moved in on that side. Ready flags on the receivers can be set false to hit
the "ready flag not set" paths.
### How to test (real HTTP)
```bash
H=http://wonder-app-vd03.zmr.zimmer.com:8085/api; K='X-API-Key: <key with the method in scope>'
curl -s -X POST $H/MesMoveIn -H "$K" -H 'Content-Type: application/json' -d '{"SAPID":"990001_A","ContainerNumber":"CN-1","JobSequenceNumber":"10","WorkOrders":[{"WorkOrderNumber":"WO-1","PartNumber":"PN-1"}],"OperatorName":"me"}'
curl -s -X POST $H/MesMoveOut -H "$K" -H 'Content-Type: application/json' -d '{"SAPID":"990001_A","ContainerNumber":"CN-1","NumberWorkOrders":1,"WorkOrders":[{"WorkOrderNumber":"WO-1","PartNumber":"PN-1"}],"OperatorName":"me"}'
curl -s -X POST $H/DelmiaRecipeDownload -H "$K" -H 'Content-Type: application/json' -d '{"MachineCode":"SBTEST01","DownloadPath":"\\\\share\\r1.xml","WorkOrderNumber":"WO-1","PartNumber":"PN-1","JobStepNumber":"20","Username":"me"}'
curl -s -X POST $H/SimpleAlarmStatusRequest -H "$K" -H 'Content-Type: application/json' -d '{"SAPID":"990001"}'
curl -s -X POST $H/AlarmStatusRequest -H "$K" -H 'Content-Type: application/json' -d '{"MachineFilter":{"Code":"SBTEST01"},"AlarmFilter":{"MinSeverity":500}}'
```
`_A`/`_B` picks the Left/Right receiver, `_LT` makes it a leak-test move (MoveOut then returns the six `LT*`
rows instead of the 16 cycle rows). Trip an alarm without a key via the Flow Debugger (CLI
`flow-debug run-value-change --instance-id 9 --attribute VacuumPressure --value 12`, clear with `0.5`), or
from the Central UI Debug View. The gate needs no key — it drives the methods through `flow-debug run-method`.
### Known semantics (not bugs)
- Computed alarms are always `Acknowledged` → `StatusCode = "Triggered.Acked"`, `AckDT = null`, and
`AlarmFilter.IncludeAcked = false` returns nothing for this machine (it only bites on native mirrors).
- `Description` falls back to the alarm name (computed alarms carry no message/type text).
- The alarm methods accept an exact BTDB SAPID first, then its leading digits (so `990001_A` works), and
strip a trailing `A`/`B` from the BTDB Code to find the instance (MesMoveOut's convention);
`HierarchicalName` keeps the BTDB Code, as the legacy service did.
- A machine without an `AlarmStatus` script (e.g. CvdReactor's Z28061) answers
`Alarm status is not supported on machine '<code>'`.
## Rollback
Upgrade: stop Site, stop Central; `RESTORE DATABASE [ScadaBridge] FROM DISK =
'E:\ApiInstall\ScadaBridge-backups\ScadaBridge_pre-fe89363b_20260928T095825.bak' WITH REPLACE`;
rename `app` → `app.failed`, `app.bak.20260928T095846` → `app`, same for `cli`; start Central, then Site.
(The migrations are additive, so `2b9a7c3c` would also run against the 57-migration DB — the restore just
makes it exact.) Test machines: `instance delete` 9/10, `template delete` 30/29/28, restore the stub body of
method 9 (`return new { WasSuccessful = true, Alarms = Array.Empty<Object>() };`) and delete method 12;
`DELETE FROM dbo.Machine WHERE MachineID IN (203, 204) AND LastUpdateBy = N'scadabridge-test-seed'`.
Staging left on the box under `E:\psrem-stage\fe89363b\` (zip, unpacked tree, migration SQL, prep + swap
scripts, `.reg` exports). The `_appsettings-keep-20260928T095846` copy and `*.bak.20260928T095846`
directories can go once the build has soaked.
@@ -119,7 +119,7 @@ All 7 fix-now items landed via PLAN-04/05/06/07/08 (verified in review 08 round
| 123 | **Rail nav scroll pane (`.rail-scroll` + the `#theme-rail > .side-rail { height: 100% }` fill rule) lives in app-local `site.css`/`NavMenu.razor`, not the `ZB.MOM.WW.Theme` kit** *(Low)*. Every other kit consumer (OtOpcUa, MxGateway, HistorianGateway, ignitionoee) has the same unbounded-nav-pushes-the-footer-off-screen shape and would need the identical fix copy-pasted. | `docs/plans/2026-09-14-pending-updates-design.md` §3.5 | Scoped to ScadaBridge for this change; upstreaming into the shared kit is a separate cross-repo release, not a one-app CSS tweak. | A second kit consumer hits the same long-nav clipping and copies the fix instead of consuming it from the kit. |
| 124 | **No template-graph-watermark short-circuit on the pending-updates fleet flatten** *(Low)*. `PendingUpdatesEvaluator.EvaluateAsync` re-flattens every deployed instance through `DeploymentService.GetInstanceStalenessAsync` on every cache-TTL expiry (`PendingUpdatesCacheTtl`, default 60 s) while any Deployer/Admin circuit is open, even when nothing in the template/instance/artifact graph has changed since the last evaluation. | `docs/plans/2026-09-14-pending-updates-design.md` §3.2 ("Not in scope") | The single-flight TTL cache already bounds the cost to one fleet flatten per TTL fleet-wide (not per viewer); a watermark short-circuit is a further optimization, not a correctness need, and needs a cheap "has anything changed" signal that does not exist yet. | The fleet flatten's cost (instance count x template depth) becomes measurable against `PendingUpdatesCacheTtl` on a large deployment. |
| 125 | **Pending-updates counts have no Health-dashboard tile or CLI surface** *(Low)*. The aggregate (`PendingUpdatesSnapshot.StaleInstanceCount` / `StaleArtifactSiteCount`) is consumed only by the Central UI Sites page and rail indicator; there is no Health dashboard KPI tile and no `deploy`/`site` CLI command surfacing it — `site list` shows only the raw `artifactRevisionHash`/`artifactsDeployedAt` fields on each site, not whether they are current. | `docs/plans/2026-09-14-pending-updates-design.md` §3.2 ("Not in scope") | Out of scope for the UI-focused ask; the CLI/Health surfaces are a separate consumer of the same `IPendingUpdatesCache`. | An operator wants the pending-updates counts without opening the Central UI (headless monitoring, CLI scripting). |
| 126 | **TODO — roll the two outstanding migrations to DEV vd03 and the QA pair vq03/vq04** *(deployment, blocked)*. Those boxes run `2b9a7c3c` with the DB at migration 55 (`20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration`); `main` (`ac833f5a`, pushed 2026-09-14) carries two more: **56** `20260911135501_AddAlarmMesReportable` (computed-alarm `IsMesReportable` flag) and **57** `20260914173147_AddSiteArtifactRevision` (`Sites.ArtifactRevisionHash`/`ArtifactsDeployedAt`, both nullable). Both are additive `AddColumn`s — no data upgrade, no coordinated step like the 2026-09-10 protocol-config encryption. Recipe (same as the 55 rollout): generate ONE from→to delta script `dotnet ef migrations script EnforceGlobalInstanceNamesAndProtectProtocolConfiguration AddSiteArtifactRevision --project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase --startup-project src/ZB.MOM.WW.ScadaBridge.Host -o sb-mig-55to57.sql` (NOT idempotent, identical for vd03 and the QA pair), take the pre-upgrade DB backup, stop the pair TOGETHER, apply via `sqlcmd -C -b -I` on the SQL host, publish/swap the `ac833f5a` binaries, start the pair, confirm the Central boot log shows 0 pending migrations and no "Applying migration" (the script already applied them). After upgrade every site reads *Artifacts never deployed* until one *Deploy Artifacts to All Sites* — run it as the last step. Write the deployment record `docs/deployment/deployment-records/<date>-wonder-app-{vd03,vq03-vq04}-ac833f5a.md`. | Pending-updates finish (2026-09-14) | **Blocked** by the wonder-box remote-access hold (CLAUDE.md 2026-09-11: no `psbridge`/WinRM) — the SQL step is allowed (direct SQL login), the service stop/publish/start is not. | The user clears a remote-exec channel for vd03/vq03/vq04, or hands the box steps to someone on-site. |
| 126 | **TODO — roll the two outstanding migrations to the QA pair vq03/vq04** *(deployment, open)*. **DEV vd03 DONE 2026-09-28** (`fe89363b`, 55 → 57, record `docs/deployment/deployment-records/2026-09-28-wonder-app-vd03-fe89363b.md` — the `sb-mig-55to57.sql` there is the same script QA needs). QA still runs `2b9a7c3c` with the DB at migration 55 (`20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration`); `main` (`ac833f5a`, pushed 2026-09-14) carries two more: **56** `20260911135501_AddAlarmMesReportable` (computed-alarm `IsMesReportable` flag) and **57** `20260914173147_AddSiteArtifactRevision` (`Sites.ArtifactRevisionHash`/`ArtifactsDeployedAt`, both nullable). Both are additive `AddColumn`s — no data upgrade, no coordinated step like the 2026-09-10 protocol-config encryption. Recipe (same as the 55 rollout): generate ONE from→to delta script `dotnet ef migrations script EnforceGlobalInstanceNamesAndProtectProtocolConfiguration AddSiteArtifactRevision --project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase --startup-project src/ZB.MOM.WW.ScadaBridge.Host -o sb-mig-55to57.sql` (NOT idempotent, identical for vd03 and the QA pair), take the pre-upgrade DB backup, stop the pair TOGETHER, apply via `sqlcmd -C -b -I` on the SQL host, publish/swap the `ac833f5a` binaries, start the pair, confirm the Central boot log shows 0 pending migrations and no "Applying migration" (the script already applied them). After upgrade every site reads *Artifacts never deployed* until one *Deploy Artifacts to All Sites* — run it as the last step. Write the deployment record `docs/deployment/deployment-records/<date>-wonder-app-{vd03,vq03-vq04}-ac833f5a.md`. | Pending-updates finish (2026-09-14) | ~~Blocked by the 2026-09-11 remote-access hold~~ — **unblocked 2026-09-28**: `winrm` is re-enabled for deployment activities (CLAUDE.md). vd03 done; QA waits only for the user to schedule its window. | The user asks for the QA (vq03/vq04) window. |
## Resolved (verified against the code 2026-07-10)
Rows removed from the Deferred table above once confirmed shipped. Kept here for traceability.