Files
ScadaBridge/docker/seed-mes-test-machine.sh
T
Joseph Doherty b3021e7c5f feat(scripts): read-only, allow-listed Files global for site scripts; Delmia test receiver parses recipe files
Script file access (design docs/plans/2026-09-28-script-file-access-design.md): Files.ReadTextAsync /
ReadLinesAsync / ExistsAsync on site ScriptGlobals (+ Instance.Files), backed by the site-singleton
ScriptFileReader. The one host-mediated exception to the System.IO ban - scripts still never name a
System.IO type. OFF by default: ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots (empty = every call
throws Disabled) + MaxFileBytes (1 MiB, validated 1..64 MiB, roots must be absolute). Guard: fully
qualified, no device paths / ADS, canonical path strictly inside a root (directory-boundary match), no
reparse point on the file or any directory below the root, size cap before and during the read.
Errors are one Commons type, ScriptFileAccessException(+ScriptFileAccessFailure); Commons.Types.Scripts
joins the default script imports on every surface so it binds by short name. Mirrored on
ScriptCompileSurface (CompileFiles, parity-pinned) and the Test Run sandbox (binds, refuses Disabled).
Inbound (central) scripts do not get it.

Rig: docker/recipes (sample RecipeValueName,RecipeValue files incl. an invalid one) bind-mounted
read-only at /app/recipes on both site-a nodes with AllowedRoots [/app/recipes]. The TestMesMachine
SimDelmiaRecipe simulator now reads + parses the downloaded file, converts each value to its recipe tag's
type and loads it all-or-nothing (14 new TestDelmiaReceiver attributes); verify-mes-test-machine.sh
group A covers accept / re-download / invalid lines / NotFound / OutsideAllowedRoots, VERIFY_GROUPS
selects groups (rig: AD).
2026-09-28 10:36:42 -04:00

605 lines
42 KiB
Bash
Executable File

#!/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.
# SimDelmiaRecipe READS + PARSES the downloaded recipe file via the Files global
# and loads it into the TestDelmiaReceiver recipe tags (see docker/recipes/).
# - 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.
#
# Recipe files: the site needs ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots covering the folder the
# DownloadPath points into (rig: docker/recipes bind-mounted at /app/recipes on site-a; vd03:
# E:\ApiInstall\ScadaBridge\recipes). Without it every download is rejected "recipe file not readable (Disabled)".
#
# 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 handshake + 14 recipe 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
# Recipe tags the simulator loads from the downloaded file (the Galaxy object's recipe schema stand-in).
# The type here is what each file value is converted to — keep it in step with RECIPE_SCHEMA in SimDelmiaRecipe.
ensure_attr "$DR_ID" RecipeName --data-type String --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" RecipeRevision --data-type Int32 --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" CoatingType --data-type String --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" ReactorTempSP --data-type Double --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" PotTempSP --data-type Int32 --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" ArgonFlowSP --data-type Double --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" HydrogenFlowSP --data-type Int32 --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" ChlorineFlowSP --data-type Double --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" VacuumSP --data-type Double --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" TargetRuntime --data-type Int32 --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" PurgeEnabled --data-type Boolean --value false --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" OperatorNote --data-type String --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" RecipeValueCount --data-type Int32 --value 0 --description "How many values the last accepted download loaded"
ensure_attr "$DR_ID" RecipeLoadedFile --data-type String --description "Path of the last accepted recipe file"
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. On the rising edge of RecipeDownloadFlag it READS the downloaded recipe
// file through the Files global (script file access, design 2026-09-28 — only beneath the site's configured
// ScriptFileAccess:AllowedRoots), parses "RecipeValueName,RecipeValue" lines, converts each value to its
// recipe tag's type and loads them. All-or-nothing: any bad line rejects the download and loads nothing.
// Blank lines and '#' comments are ignored; everything after the FIRST comma is the value; a value wrapped
// in double quotes is unwrapped. SimRecipeReject / SimNoResponse knobs still apply.
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()?.Trim() ?? "";
var wo = (await recv.Attributes.GetAsync("WorkOrderNumber"))?.ToString() ?? "";
// RECIPE_SCHEMA — recipe tag name -> target type (keep in step with the TestDelmiaReceiver attributes).
var schema = new System.Collections.Generic.Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) {
["RecipeName"] = "String", ["RecipeRevision"] = "Int32", ["CoatingType"] = "String",
["ReactorTempSP"] = "Double", ["PotTempSP"] = "Int32", ["ArgonFlowSP"] = "Double",
["HydrogenFlowSP"] = "Int32", ["ChlorineFlowSP"] = "Double", ["VacuumSP"] = "Double",
["TargetRuntime"] = "Int32", ["PurgeEnabled"] = "Boolean", ["OperatorNote"] = "String" };
var inv = System.Globalization.CultureInfo.InvariantCulture;
var errors = new System.Collections.Generic.List<string>();
var values = new System.Collections.Generic.List<(string Name, object Value)>();
if (IsTrue(await Attributes.GetAsync("SimRecipeReject"))) errors.Add("simulated reject (SimRecipeReject)");
else if (wo.Length == 0) errors.Add("missing work order number");
else if (path.Length == 0) errors.Add("missing download path");
else {
IReadOnlyList<string>? lines = null;
try { lines = await Files.ReadLinesAsync(path); }
catch (ScriptFileAccessException ex) { errors.Add($"recipe file not readable ({ex.Failure}): {ex.Message}"); }
for (var i = 0; lines != null && i < lines.Count; i++) {
var line = lines[i].Trim();
if (line.Length == 0 || line.StartsWith("#")) continue;
var comma = line.IndexOf(',');
if (comma <= 0) { errors.Add($"line {i + 1}: expected RecipeValueName,RecipeValue"); continue; }
var name = line.Substring(0, comma).Trim();
var raw = line.Substring(comma + 1).Trim();
if (raw.Length >= 2 && raw[0] == '"' && raw[raw.Length - 1] == '"') raw = raw.Substring(1, raw.Length - 2);
var key = schema.Keys.FirstOrDefault(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase));
if (key == null) { errors.Add($"line {i + 1}: unknown recipe value '{name}'"); continue; }
if (values.Any(v => v.Name == key)) { errors.Add($"line {i + 1}: '{key}' appears more than once"); continue; }
object? typed = null;
switch (schema[key]) {
case "Int32": if (int.TryParse(raw, System.Globalization.NumberStyles.Integer, inv, out var n)) typed = n; break;
case "Double": if (double.TryParse(raw, System.Globalization.NumberStyles.Float, inv, out var d)) typed = d; break;
case "Boolean": if (bool.TryParse(raw, out var bv)) typed = bv; break;
default: typed = raw; break;
}
if (typed == null) { errors.Add($"line {i + 1}: {key} expects {schema[key]}, got '{raw}'"); continue; }
values.Add((key, typed));
}
if (lines != null && errors.Count == 0 && values.Count == 0) errors.Add("recipe file contains no values");
}
var ok = errors.Count == 0;
if (ok) {
foreach (var (name, value) in values) await recv.Attributes.SetAsync(name, value);
await recv.Attributes.SetAsync("RecipeValueCount", values.Count);
await recv.Attributes.SetAsync("RecipeLoadedFile", path);
}
var file = path.Substring(path.LastIndexOfAny(new[] { '\\', '/' }) + 1);
var text = ok ? $"Loaded {values.Count} recipe values from {file} for {wo}"
: "Recipe rejected: " + string.Join("; ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} more)" : "");
if (!ok) Log.Warn(text);
await recv.Attributes.SetAsync("RecipeProcessResult", ok);
await recv.Attributes.SetAsync("RecipeProcessResultText", text);
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