a5c3de63e9
Codec-encoded scalars ("True", "42.5") reached RealOpcUaClient.WriteValuesAsync
as strings and were sent as String Variants, so a Boolean/Double node answered
BadTypeMismatch (0x80740000).
RealOpcUaClient now reads each written node's DataType attribute once per
session (batched, cached, cleared on connect/disconnect; derived types and
enumerations resolved via the supertype chain) and converts non-null scalar
values with the new OpcUaWriteCoercion table: Boolean, SByte..UInt64, Float,
Double, String, DateTime, Guid, all culture-invariant. Overflow, fractional
values to integer types and unparseable strings are refused client-side with
BadTypeMismatch and a message naming the value and target type - never a
rounded value. Arrays, null, unmapped types and nodes whose DataType read
fails pass through unchanged. WriteValueAsync now surfaces the outcome's own
status code instead of always BadNodeIdUnknown. MxGateway path untouched.
Tests: OpcUaWriteCoercionTests (coercion table incl. overflow, invalid and
culture-sensitive strings) and OpcUaWriteCoercionLiveTests (RequiresOpcUa:
String writes to Double/Boolean/UInt32 opc-plc nodes read back typed; reverting
the coercion reproduces 0x80740000). Component-DataConnectionLayer.md updated.
94 lines
6.2 KiB
Bash
Executable File
94 lines
6.2 KiB
Bash
Executable File
#!/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. Gitea #58 since made RealOpcUaClient
|
|
# coerce scalar writes to the node DataType, so the Boolean nodes would now work too; the seed
|
|
# keeps the String nodes because 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 <id> && scadabridge template delete --id <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<string, object?>();
|
|
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."
|