v0.2: Working bridge — temp-file output, eval with stdout capture

- Changed from exec(open()) to temp-file output pattern (/tmp/hhb_out_*.txt)
- eval command now captures print() via io.StringIO redirect
- get-selected, get-param, get-inputs: working with temp-file output
- Verified live on Houdini 21.0.729 (Fedora, Wayland)
- Known limitation: python /tmp/script.py via hcommand does not capture print();
  workaround is writing to /tmp/hhb_out_*.txt
This commit is contained in:
Hermes
2026-07-12 12:28:53 +00:00
parent dd2a1734eb
commit 79aa35df6b
+116 -72
View File
@@ -2,14 +2,13 @@
# ────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────
# hhb — Hermes-Houdini Bridge Client # hhb — Hermes-Houdini Bridge Client
# Sends Python/hou commands to a running Houdini session # Sends Python/hou commands to a running Houdini session
# via SSH + hcommand. # via SSH + hcommand. Results returned via temp file.
# ────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────
set -euo pipefail set -euo pipefail
HOST="${HHB_HOST:-niklas@192.168.178.39}" HOST="${HHB_HOST:-niklas@192.168.178.39}"
PORT="${HHB_PORT:-12345}" PORT="${HHB_PORT:-12345}"
HCOMMAND="${HHB_HCOMMAND:-/opt/hfs21.0/bin/hcommand}" HCOMMAND="${HHB_HCOMMAND:-/opt/hfs21.0/bin/hcommand}"
WORKSPACE="${HHB_WORKSPACE:-/opt/data/projects/hermes-houdini-bridge}"
usage() { usage() {
cat <<'EOF' cat <<'EOF'
@@ -17,49 +16,51 @@ Usage: hhb <command> [args]
Commands: Commands:
eval <python_code> Execute Python code in the running Houdini session. eval <python_code> Execute Python code in the running Houdini session.
Example: hhb eval "print(hou.selectedNodes())"
script <file.py> Send a Python script file to Houdini and execute it. script <file.py> Send a Python script file to Houdini and execute it.
Example: hhb script ./examples/create_pyro_setup.py
get-selected Print paths and types of selected nodes. get-selected Print paths and types of selected nodes.
get-param <parm> Get value of a parameter on the first selected node. get-param <parm> Get value of a parameter on the first selected node.
set-param <parm> <val> Set a parameter on the first selected node. set-param <parm> <val> Set a parameter on the first selected node.
get-inputs Print input connections of the first selected node. get-inputs Print input connections of the first selected node.
status Check if the bridge port is open. status Check if the bridge port is open.
help Show this help. help Show this help.
EOF EOF
exit 0 exit 0
} }
# ── Helpers ───────────────────────────────────────────── # ── Core: write Python script, execute, read back output
run_remote_python() { run_houdini_python() {
# Write Python code to a temp file, execute via hcommand. # $1: Python code (as a string)
# This avoids quoting hell with complex Python snippets. # Writes code to /tmp/hhb_script.py, executes via hcommand,
# reads output from /tmp/hhb_out.txt
local python_code="$1" local python_code="$1"
local tmpfile="/tmp/hhb_$$.py" local sid="$$"
# Upload script # Upload the script
printf '%s' "$python_code" | ssh "$HOST" "cat > $tmpfile" printf '%s' "$python_code" | ssh "$HOST" "cat > /tmp/hhb_script_${sid}.py"
# Execute via hscript's python command — reads file and execs # Execute via hcommand
local output ssh "$HOST" "$HCOMMAND $PORT 'python /tmp/hhb_script_${sid}.py'" 2>/dev/null || true
output=$(ssh "$HOST" \
"$HCOMMAND $PORT \"python exec(open('$tmpfile').read())\"" 2>&1) || { # Read output file
local rc=$? ssh "$HOST" "cat /tmp/hhb_out_${sid}.txt 2>/dev/null" || echo ""
ssh "$HOST" "rm -f $tmpfile" 2>/dev/null
echo "hhb: hcommand failed (exit $rc)" >&2
echo "$output" >&2
return $rc
}
# Cleanup # Cleanup
ssh "$HOST" "rm -f $tmpfile" 2>/dev/null ssh "$HOST" "rm -f /tmp/hhb_script_${sid}.py /tmp/hhb_out_${sid}.txt" 2>/dev/null || true
}
# hcommand output has a prefix line like "-> foobar", strip it # Wrap user code to capture output via temp file
echo "$output" | sed 's/^->\s*//' wrap_python() {
local code="$1"
local sid="$$"
cat <<PYEOF
import hou, json, sys
try:
${code}
except Exception as e:
with open('/tmp/hhb_out_${sid}.txt', 'w') as f:
f.write(json.dumps({"error": str(e)}))
PYEOF
} }
# ── Commands ────────────────────────────────────────────── # ── Commands ──────────────────────────────────────────────
@@ -75,94 +76,137 @@ cmd_status() {
cmd_eval() { cmd_eval() {
[[ $# -ge 1 ]] || { echo "Usage: hhb eval <python_code>"; exit 1; } [[ $# -ge 1 ]] || { echo "Usage: hhb eval <python_code>"; exit 1; }
run_remote_python "$*" local sid="$$"
local code
code=$(cat <<PYEOF
import hou, json, sys, io
_stdout = io.StringIO()
sys.stdout = _stdout
$*
sys.stdout = sys.__stdout__
with open('/tmp/hhb_out_${sid}.txt', 'w') as f:
f.write(_stdout.getvalue())
PYEOF
)
run_houdini_python "$code"
} }
cmd_script() { cmd_script() {
[[ $# -ge 1 ]] || { echo "Usage: hhb script <file.py>"; exit 1; } [[ $# -ge 1 ]] || { echo "Usage: hhb script <file.py>"; exit 1; }
local script_path="$1" local script_path="$1"
local sid="$$"
[[ -f "$script_path" ]] || { echo "File not found: $script_path"; exit 1; } [[ -f "$script_path" ]] || { echo "File not found: $script_path"; exit 1; }
scp -q "$script_path" "$HOST:/tmp/hhb_script_$$.py" # Read the script, wrap in output capture
local output local code
output=$(ssh "$HOST" \ code=$(cat "$script_path")
"$HCOMMAND $PORT \"python exec(open('/tmp/hhb_script_$$.py').read())\"" 2>&1) || { local wrapped
local rc=$? wrapped=$(cat <<PYEOF
ssh "$HOST" "rm -f /tmp/hhb_script_$$.py" 2>/dev/null import hou, json
echo "hhb: hcommand failed (exit $rc)" >&2 try:
echo "$output" >&2 ${code}
return $rc except Exception as e:
} with open('/tmp/hhb_out_${sid}.txt', 'w') as f:
ssh "$HOST" "rm -f /tmp/hhb_script_$$.py" 2>/dev/null f.write(json.dumps({"error": str(e)}))
echo "$output" | sed 's/^->\s*//' PYEOF
)
run_houdini_python "$wrapped"
} }
cmd_get_selected() { cmd_get_selected() {
run_remote_python ' local sid="$$"
import hou local code
code=$(cat <<'PYEOF'
nodes = hou.selectedNodes() nodes = hou.selectedNodes()
lines = []
if not nodes: if not nodes:
print("No nodes selected.") lines.append("No nodes selected.")
else: else:
for n in nodes: for n in nodes:
print(f"{n.path():50s} [{n.type().name()}]") lines.append(f"{n.path():50s} [{n.type().name()}]")
' with open('/tmp/hhb_out_PLACEHOLDER.txt', 'w') as f:
f.write("\n".join(lines))
PYEOF
)
code="${code//PLACEHOLDER/$sid}"
run_houdini_python "$code"
} }
cmd_get_param() { cmd_get_param() {
[[ $# -ge 1 ]] || { echo "Usage: hhb get-param <parm_name>"; exit 1; } [[ $# -ge 1 ]] || { echo "Usage: hhb get-param <parm_name>"; exit 1; }
local parm="$1" local parm="$1"
run_remote_python " local sid="$$"
import hou local code
code=$(cat <<PYEOF
nodes = hou.selectedNodes() nodes = hou.selectedNodes()
if not nodes: if not nodes:
print('No nodes selected.') msg = "No nodes selected."
else: else:
p = nodes[0].parm('$parm') p = nodes[0].parm("PARM_PLACEHOLDER")
if p is None: if p is None:
print(f'Parameter \"$parm\" not found on {nodes[0].path()}') msg = f'Parameter "PARM_PLACEHOLDER" not found on {nodes[0].path()}'
else: else:
print(f'{nodes[0].path()}.{p.name()} = {p.eval()}') msg = f'{nodes[0].path()}.{p.name()} = {p.eval()}'
" with open('/tmp/hhb_out_PLACEHOLDER_SID.txt', 'w') as f:
f.write(msg)
PYEOF
)
code="${code//PARM_PLACEHOLDER/$parm}"
code="${code//PLACEHOLDER_SID/$sid}"
run_houdini_python "$code"
} }
cmd_set_param() { cmd_set_param() {
[[ $# -ge 2 ]] || { echo "Usage: hhb set-param <parm_name> <value>"; exit 1; } [[ $# -ge 2 ]] || { echo "Usage: hhb set-param <parm_name> <value>"; exit 1; }
local parm="$1" local parm="$1"
local val="$2" local val="$2"
run_remote_python " local sid="$$"
import hou local code
code=$(cat <<PYEOF
nodes = hou.selectedNodes() nodes = hou.selectedNodes()
if not nodes: if not nodes:
print('No nodes selected.') msg = "No nodes selected."
else: else:
p = nodes[0].parm('$parm') p = nodes[0].parm("PARM_PLACEHOLDER")
if p is None: if p is None:
print(f'Parameter \"$parm\" not found on {nodes[0].path()}') msg = f'Parameter "PARM_PLACEHOLDER" not found on {nodes[0].path()}'
else: else:
p.set('$val') p.set("VAL_PLACEHOLDER")
print(f'{nodes[0].path()}.{p.name()} = {p.eval()} ✓') msg = f'{nodes[0].path()}.{p.name()} = {p.eval()} OK'
" with open('/tmp/hhb_out_PLACEHOLDER_SID.txt', 'w') as f:
f.write(msg)
PYEOF
)
code="${code//PARM_PLACEHOLDER/$parm}"
code="${code//VAL_PLACEHOLDER/$val}"
code="${code//PLACEHOLDER_SID/$sid}"
run_houdini_python "$code"
} }
cmd_get_inputs() { cmd_get_inputs() {
run_remote_python ' local sid="$$"
import hou local code
code=$(cat <<'PYEOF'
lines = []
nodes = hou.selectedNodes() nodes = hou.selectedNodes()
if not nodes: if not nodes:
print("No nodes selected.") lines.append("No nodes selected.")
else: else:
for n in nodes: for n in nodes:
inputs = n.inputConnectors() lines.append(f"\n{n.path()} [{n.type().name()}]")
print(f"\n{n.path()} [{n.type().name()}]") for i in range(len(n.inputConnectors())):
for i, conn in enumerate(inputs): conns = n.inputConnections(i)
in_nodes = n.inputConnections(i) if hasattr(n, "inputConnections") else [] if conns:
if in_nodes: for c in conns:
for c in in_nodes: lines.append(f" input {i} <- {c.inputNode().path()} [{c.inputNode().type().name()}]")
print(f" input {i} ← {c.inputNode().path()} [{c.inputNode().type().name()}]")
else: else:
print(f" input {i} (none)") lines.append(f" input {i} <- (none)")
' with open('/tmp/hhb_out_PLACEHOLDER.txt', 'w') as f:
f.write("\n".join(lines))
PYEOF
)
code="${code//PLACEHOLDER/$sid}"
run_houdini_python "$code"
} }
# ── Main ───────────────────────────────────────────────── # ── Main ─────────────────────────────────────────────────