#!/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 \ # RECIPE_ROOT='E:\ApiInstall\ScadaBridge\recipes' RECIPE_OUTSIDE='E:\ApiInstall\ScadaBridge\app\appsettings.Site.json' \ # bash docker/verify-mes-test-machine.sh # # Group A reads the docker/recipes/ sample files through the Files global: RECIPE_ROOT is where they live # ON THE SITE NODE (default /app/recipes, the rig's site-a mount) and must be inside the site's # ScriptFileAccess:AllowedRoots; RECIPE_OUTSIDE is an existing file OUTSIDE the roots (proves the allow-list). # # 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}" export VERIFY_GROUPS="${VERIFY_GROUPS:-ABCD}" # the rig has no BTDB: VERIFY_GROUPS=AD there export RECIPE_ROOT="${RECIPE_ROOT:-/app/recipes}" RECIPE_OUTSIDE="${RECIPE_OUTSIDE:-/app/appsettings.Site.json}" 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 []) def group_A(): print("A. DelmiaRecipeDownload -> recipe file read + parsed + loaded (Files global)") ROOT, OUTSIDE = os.environ["RECIPE_ROOT"], os.environ["RECIPE_OUTSIDE"] SEP = "\\" if "\\" in ROOT else "/" def recipe(name): return ROOT.rstrip("/\\") + SEP + name def download(path, wo="WO-1001"): return call("DelmiaRecipeDownload", {"MachineCode": "SBTEST01", "DownloadPath": path, "WorkOrderNumber": wo, "PartNumber": "PN-2001", "JobStepNumber": "20", "Username": "sb-test"}) def recv_tags(): snap = cli("debug", "snapshot", "--id", str(instances["SBTEST01"])) return {a["attributeName"].split(".", 1)[1]: a["value"] for a in snap["attributeValues"] if a["attributeName"].startswith("DelmiaReceiver.")} r = download(recipe("SBTEST-R100.txt")) check("A1 R100 accepted, 12 values", r.get("Result") is True and r.get("ResultText") == "Loaded 12 recipe values from SBTEST-R100.txt for WO-1001", r) t = recv_tags() check("A2 R100 values loaded with their types", t.get("RecipeName") == "Aluminide Coat R100" and t.get("RecipeRevision") == "7" and t.get("ReactorTempSP") == "1050.5" and t.get("PotTempSP") == "980" and t.get("PurgeEnabled", "").lower() == "true" and t.get("RecipeValueCount") == "12" and t.get("OperatorNote") == "Load parts cold, verify fixture ID", t) r = download(recipe("SBTEST-R200.txt")) t = recv_tags() check("A3 R200 re-download replaces every value (quoted text unwrapped)", r.get("Result") is True and t.get("CoatingType") == "Chromide" and t.get("ChlorineFlowSP") == "11.75" and t.get("PurgeEnabled", "").lower() == "false" and t.get("OperatorNote") == "Quoted text, commas kept", (r, t)) r = download(recipe("SBTEST-R900-invalid.txt")) txt = r.get("ResultText", "") check("A4 invalid recipe rejected with line numbers", r.get("Result") is False and "line 4" in txt and "line 5: unknown recipe value 'MysterySetting'" in txt and "line 6: PotTempSP expects Int32" in txt, r) check("A5 rejected download loaded nothing (still R200)", recv_tags().get("RecipeName") == "Chromide Coat R200") r = download(recipe("SBTEST-NOPE.txt")) check("A6 missing file -> NotFound", r.get("Result") is False and "(NotFound)" in r.get("ResultText", ""), r) r = download(OUTSIDE) check("A7 file outside AllowedRoots -> refused", r.get("Result") is False and "(OutsideAllowedRoots)" in r.get("ResultText", ""), r) r = download(recipe("SBTEST-R100.txt"), wo="") check("A8 blank work order rejected", r.get("Result") is False and "work order" in r.get("ResultText", ""), r) def group_B(): 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) def group_C(): 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) def group_D(): print("D. Reset alarm sources") for inst in ("SBTEST01", "SBTEST02"): write(inst, "VacuumPressure", 0.5); write(inst, "DoorOpen", "false"); write(inst, "SensorStatus", 0) if "C" in os.environ["VERIFY_GROUPS"].upper(): # the check reads through BTDB, like group C r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST02"}}) check("D1 cleared", r.get("WasSuccessful") is True and names(r) == [], names(r)) GROUPS = os.environ["VERIFY_GROUPS"].upper() for g in "ABCD": if g in GROUPS: globals()["group_" + g]() print(f"\n{'PASS' if failed == 0 else 'FAIL'} {passed}/{passed + failed}") sys.exit(1 if failed else 0) PY