diff --git a/docker/seed-mes-test-machine.sh b/docker/seed-mes-test-machine.sh new file mode 100755 index 00000000..46491e85 --- /dev/null +++ b/docker/seed-mes-test-machine.sh @@ -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: _[_LT]; _A => LeftMESReceiver, _B => RightMESReceiver; _LT => MoveInType "LT". +System.Func IsTrue = v => { var s=v?.ToString(); return (v is bool b && b)||s=="1"||string.Equals(s,"true",System.StringComparison.OrdinalIgnoreCase); }; +System.Func 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 IsTrue = v => { var s=v?.ToString(); return (v is bool b && b)||s=="1"||string.Equals(s,"true",System.StringComparison.OrdinalIgnoreCase); }; +System.Func P = k => Parameters.ContainsKey(k) ? Parameters[k] : null; +var none = new System.Collections.Generic.List(); +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(); + 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 IsTrue = v => { var s=v?.ToString(); return (v is bool b && b)||s=="1"||string.Equals(s,"true",System.StringComparison.OrdinalIgnoreCase); }; +System.Func 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("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.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() }; +} +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 < 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().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 < 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().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 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 P = k => Parameters.ContainsKey(k) ? Parameters[k] : null; +var raw = (P("SAPID")?.ToString() ?? "").Trim(); +var none = Array.Empty(); +try { + string? code = null; + if (raw.Length > 0) { + code = await Database.QuerySingleAsync("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("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(); +var mf = Parameters.ContainsKey("MachineFilter") ? Parameters["MachineFilter"] as System.Collections.Generic.IDictionary : null; +var af = Parameters.ContainsKey("AlarmFilter") ? Parameters["AlarmFilter"] as System.Collections.Generic.IDictionary : 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("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("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("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("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("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 diff --git a/docker/verify-mes-test-machine.sh b/docker/verify-mes-test-machine.sh new file mode 100755 index 00000000..34c3278a --- /dev/null +++ b/docker/verify-mes-test-machine.sh @@ -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 diff --git a/docs/deployment/deployment-records/2026-09-28-wonder-app-vd03-fe89363b.md b/docs/deployment/deployment-records/2026-09-28-wonder-app-vd03-fe89363b.md new file mode 100644 index 00000000..3480748f --- /dev/null +++ b/docs/deployment/deployment-records/2026-09-28-wonder-app-vd03-fe89363b.md @@ -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: ' +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 ''`. + +## 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() };`) 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. diff --git a/docs/plans/2026-07-08-deferred-work-register.md b/docs/plans/2026-07-08-deferred-work-register.md index f4ec105f..de3e6923 100644 --- a/docs/plans/2026-07-08-deferred-work-register.md +++ b/docs/plans/2026-07-08-deferred-work-register.md @@ -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/-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/-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.