qemu: simulated Modbus devices on the rig's RS485 bus

ui-drive.sh --rs485-devices ADDR:SLUG[,...] attaches the VM's RS485 UART
to a socket, turns it into a pty with socat and serves the named corpus
profiles on it with flare-edge tools/modbus-sim/mbsim.py, so warden-modbus
in the guest discovers and identifies devices as it would real controllers.
Verified: unit 5 as smartgen-hgm6100n4g appears in the status json as
SmartGen HGM6110N, online, 8 ms, and RS485/Devices lists it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3G6m9Aw5RyVY4ZowtKzEj
This commit is contained in:
Noah
2026-09-08 17:03:13 -06:00
co-authored by Claude Fable 5.1
parent a512d56650
commit e60d47846b
+34 -2
View File
@@ -14,7 +14,7 @@
#
# FAILS CLOSED on missing prerequisites.
#
# Usage: ui-drive.sh [--seed FILE] [--refs FILE] <zImage-virt> <script> [out-dir]
# Usage: ui-drive.sh [--seed FILE] [--refs FILE] [--rs485-devices LIST] <zImage-virt> <script> [out-dir]
# <script> is a qmp.py `drive` script: see its docstring for the commands.
# --seed FILE a seed manifest (flat YAML key: value, see flare-edge
# tools/seed-fixtures.py) run through that tool and staged
@@ -25,17 +25,27 @@
# convention as ota-apply.sh / portal-scenario.sh).
# --refs FILE passed straight through to qmp.py drive's --refs (the
# region/ocr reference JSON); see qmp.py's own docstring.
# --rs485-devices ADDR:SLUG[,ADDR:SLUG...]
# put simulated Modbus devices on the VM's RS485 bus: the
# UART is attached to a unix socket (run.sh --rs485), socat
# turns it into a pty, and flare-edge tools/modbus-sim/mbsim.py
# serves the named corpus profiles on it, so warden-modbus in
# the guest discovers and identifies devices the way it does
# on a panel with real controllers on the header. Needs
# FLARE_EDGE and socat.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/
QDIR="$(cd "$HERE/.." && pwd)" # qemu/
SEED_FILE=""
REFS_FILE=""
RS485_DEVICES=""
ARGS=()
while [ $# -gt 0 ]; do
case "$1" in
--seed) SEED_FILE="${2:?--seed needs a manifest path}"; shift 2 ;;
--refs) REFS_FILE="${2:?--refs needs a path}"; shift 2 ;;
--rs485-devices) RS485_DEVICES="${2:?--rs485-devices needs ADDR:SLUG[,...]}"; shift 2 ;;
--) shift; ARGS+=("$@"); break ;;
-*) echo "FATAL: unknown option '$1' (usage: $0 [--seed FILE] [--refs FILE] <zImage> <script> [out-dir])" >&2; exit 1 ;;
*) ARGS+=("$1"); shift ;;
@@ -89,13 +99,16 @@ RUN_OUT="$(mktemp -d "${TMPDIR:-/tmp}/wqd-out.XXXXXX")"
export OUT="$RUN_OUT"
export BUSYBOX="${BUSYBOX:-$QDIR/out/busybox-armv7l}"
QEMU_PID=""
SIM_PIDS=""
cleanup() {
for p in $SIM_PIDS; do kill "$p" 2>/dev/null || true; done
if [ -n "$QEMU_PID" ]; then
python3 "$HERE/qmp.py" "$WORK/qmp.sock" quit 2>/dev/null || true
sleep 1
kill "$QEMU_PID" 2>/dev/null || true
fi
cp "$WORK/console.log" "$OUTDIR/console.log" 2>/dev/null || true
cp "$WORK/mbsim.log" "$OUTDIR/mbsim.log" 2>/dev/null || true
rm -rf "$WORK" "$RUN_OUT"
}
trap cleanup EXIT
@@ -119,7 +132,7 @@ for _attempt in 1 2 3; do
PORT=$((21000 + RANDOM % 20000))
: > "$WORK/console.log"
bash "$QDIR/run.sh" --kernel "$ZIMAGE" --display headless --qmp "$WORK/qmp.sock" \
--ctl "$WORK/ctl.sock" \
--ctl "$WORK/ctl.sock" ${RS485_DEVICES:+--rs485 "$WORK/rs.sock"} \
--ssh-port "$PORT" --http-port $((PORT + 1)) --api-port $((PORT + 2)) \
> "$WORK/console.log" 2>&1 &
QEMU_PID=$!
@@ -139,6 +152,25 @@ if [ -z "$QEMU_PID" ] || ! kill -0 "$QEMU_PID" 2>/dev/null; then
exit 1
fi
if [ -n "$RS485_DEVICES" ]; then
# qemu created rs.sock at launch (server=on,wait=off). socat gives the
# simulator the serial device it expects; mbsim then answers the guest's
# Modbus polls with the corpus profiles' own register maps.
command -v socat >/dev/null || { echo "FATAL: --rs485-devices needs socat" >&2; exit 1; }
[ -n "${FLARE_EDGE:-}" ] && [ -f "$FLARE_EDGE/tools/modbus-sim/mbsim.py" ] || {
echo "FATAL: --rs485-devices needs FLARE_EDGE to point at a flare-edge checkout (mbsim.py)" >&2; exit 1; }
for _i in $(seq 1 50); do [ -S "$WORK/rs.sock" ] && break; sleep 0.1; done
socat "UNIX-CONNECT:$WORK/rs.sock" "PTY,link=$WORK/rs.pty,raw,echo=0" > "$WORK/socat.log" 2>&1 &
SIM_PIDS="$SIM_PIDS $!"
for _i in $(seq 1 50); do [ -e "$WORK/rs.pty" ] && break; sleep 0.1; done
dev_args=()
IFS=',' read -r -a _devs <<< "$RS485_DEVICES"
for d in "${_devs[@]}"; do dev_args+=(--device "$d"); done
python3 "$FLARE_EDGE/tools/modbus-sim/mbsim.py" --port "$WORK/rs.pty" "${dev_args[@]}" > "$WORK/mbsim.log" 2>&1 &
SIM_PIDS="$SIM_PIDS $!"
echo "== rs485 simulator: $RS485_DEVICES on $WORK/rs.pty"
fi
echo "== waiting for warden-ui"
deadline=$((SECONDS + 180))
while [ $SECONDS -lt $deadline ]; do