#!/bin/bash set -euo pipefail # Seed a DATA-SOURCED flag handshake on site-a to prove the reset-aware # Attributes.WriteBatchAndWaitAsync overload (Gitea #36) against a real OPC UA # subscription — the reset echo must round-trip through the device before the # trigger is written, and a SECOND run must not fast-path on the first run's # cached Complete=true. # # What it creates (site-a / site id 1, connection id 1 "OPC PLC Simulator"): # - Template HandshakeDemo # * Trigger (String) <- ns=3;s=TestChildObject.TestString writable, NOT simulated (holds what is written) # * Complete (String) <- ns=3;s=JoeAppEngine.BTCS writable, NOT simulated # * Handshake (Call) — the reset-aware overload; returns the HandshakeResult fields # * Device (ValueChange on Trigger) — plays the PLC: on Trigger=="True" sets Complete="True" # and clears Trigger (so the next run's trigger is a fresh edge) # # WHY STRING NODES, NOT THE Boolean/Double ones (Motor.Running, Pump.Running, Motor.Speed): # a data-sourced SCALAR write leaves the InstanceActor as its codec-encoded STRING ("True", # "42.5") and RealOpcUaClient wraps it in a plain Variant, so opc-plc answers BadTypeMismatch # (0x80740000) for every non-String node — observed live 2026-09-10 (deferred-work register # row 120). Only List values are decoded before the write. The two String nodes above are # the rig's only writable String scalars, so the flags are the strings "True"/"False"; # the reset-echo ordering this gate proves is identical either way. # - Instance handshake-demo-1, bound + deployed # # Not idempotent (names must be unique). To re-seed: # scadabridge instance delete --id && scadabridge template delete --id # Prerequisites: infra (scadabridge-opcua) up, docker/deploy.sh, docker/seed-sites.sh. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge" A=(--url "${SCADABRIDGE_URL:-http://localhost:9000}" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}") SITE_ID="${SITE_ID:-1}" CONNECTION_ID="${CONNECTION_ID:-1}" INSTANCE_NAME="${INSTANCE_NAME:-handshake-demo-1}" json_id() { python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'; } echo "1/5 Creating template HandshakeDemo..." TEMPLATE_ID=$("$CLI" template create "${A[@]}" --name HandshakeDemo \ --description "Gitea #36 live gate: reset-aware WriteBatchAndWaitAsync over real OPC UA flags" | json_id) echo " id=$TEMPLATE_ID" echo "2/5 Adding data-sourced attributes..." "$CLI" template attribute add "${A[@]}" --template-id "$TEMPLATE_ID" --name Trigger --data-type String --data-source "ns=3;s=TestChildObject.TestString" --description "Handshake trigger flag as a string (writable String node, not simulated; see header)" > /dev/null "$CLI" template attribute add "${A[@]}" --template-id "$TEMPLATE_ID" --name Complete --data-type String --data-source "ns=3;s=JoeAppEngine.BTCS" --description "Handshake completion flag as a string (writable String node, not simulated; see header)" > /dev/null echo "3/5 Adding the Handshake (Call) script — reset-aware overload..." read -r -d '' HANDSHAKE_CODE <<'CS' || true // Gitea #36: reset Complete="False", WAIT for the device to echo it, then batch+trigger, then wait // for Complete=="True". Run this twice: the second run is the one the plain overload got wrong. // String flags because the rig's OPC UA sim rejects non-String scalar writes (register row 120); // the batch carries no payload values — the trigger alone rides the batch leg. var values = new Dictionary(); var hs = await Attributes.WriteBatchAndWaitAsync( values, "Trigger", "True", "Complete", "True", System.TimeSpan.FromSeconds(20), "Complete", "False", System.TimeSpan.FromSeconds(10)); Log.Info($"handshake matched={hs.Matched} phase={hs.Phase} timedOut={hs.TimedOut} resetFailed={hs.ResetFailed}"); return new { Matched = hs.Matched, Phase = hs.Phase.ToString(), TimedOut = hs.TimedOut, ResetFailed = hs.ResetFailed, Error = hs.ErrorMessage ?? "" }; CS "$CLI" template script add "${A[@]}" --template-id "$TEMPLATE_ID" --name Handshake --trigger-type Call \ --return-def '{"type":"object","properties":{"Matched":{"type":"boolean"},"Phase":{"type":"string"},"TimedOut":{"type":"boolean"},"ResetFailed":{"type":"boolean"},"Error":{"type":"string"}},"required":["Matched","Phase"]}' \ --code "$HANDSHAKE_CODE" > /dev/null echo "4/5 Adding the Device (ValueChange on Trigger) script — plays the PLC..." read -r -d '' DEVICE_CODE <<'CS' || true // Plays the device: on the rising edge of Trigger, complete the request and clear the trigger // so the NEXT run's Trigger=true is a fresh change on the subscription. Its own Trigger=false // write re-triggers this script, which returns at the guard. var t = await Attributes.GetAsync("Trigger"); var ts = t?.ToString(); if (!((t is bool b && b) || ts == "1" || string.Equals(ts, "true", System.StringComparison.OrdinalIgnoreCase))) return null; await Attributes.SetAsync("Complete", "True"); await Attributes.SetAsync("Trigger", "False"); return null; CS "$CLI" template script add "${A[@]}" --template-id "$TEMPLATE_ID" --name Device --trigger-type ValueChange \ --trigger-config '{"attributeName":"Trigger"}' --code "$DEVICE_CODE" > /dev/null echo "5/5 Creating, binding and deploying instance $INSTANCE_NAME..." INSTANCE_ID=$("$CLI" instance create "${A[@]}" --name "$INSTANCE_NAME" --template-id "$TEMPLATE_ID" --site-id "$SITE_ID" | json_id) "$CLI" instance set-bindings "${A[@]}" --id "$INSTANCE_ID" \ --bindings "[[\"Trigger\",$CONNECTION_ID],[\"Complete\",$CONNECTION_ID]]" > /dev/null "$CLI" instance deploy "${A[@]}" --id "$INSTANCE_ID" > /dev/null echo " instance id=$INSTANCE_ID" echo "" echo "=== Done. Live gate (run it TWICE — the second run is the #36 case): ===" echo " $CLI flow-debug run-script --instance-id $INSTANCE_ID --script Handshake --show-logs ${A[*]}" echo "Expect on BOTH runs: Matched=true, Phase=Response; the trace shows Complete write ->" echo "Complete wait (reset echo, >0 ms on the 2nd run) -> batch+Trigger write -> Complete wait."