commit dd2a1734eb78c659c29a5521635ea53e54a54ae6 Author: Hermes Date: Sun Jul 12 12:14:36 2026 +0000 Initial commit: Hermes-Houdini Bridge - Shelf tool to open/close command port in Houdini - hhb client script (eval, script, get-selected, set-param, etc.) - Hermes skill with standard hou module workflows - Example: create pyro setup - hou module quick reference diff --git a/README.md b/README.md new file mode 100644 index 0000000..9997a55 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Hermes-Houdini Bridge + +> Hermes Agent ↔ Houdini Python Bridge — remote node control, parameter editing, VEX injection, and scene querying via Houdini's built-in `hcommand` protocol. + +## Architecture + +``` +Hermes Agent (orange-desktop) Houdini 21.0 (fedora.fritz.box) +┌─────────────────────┐ ┌──────────────────────────────┐ +│ hhb eval "code" │──SSH──────────→│ hcommand PORT "python code" │ +│ hhb script file.py │ │ ↓ │ +│ hhb get-selected │ │ hou module (running session) │ +└─────────────────────┘ └──────────────────────────────┘ +``` + +No custom server needed — uses Houdini's built-in `openport` + `hcommand` protocol. The bridge wraps SSH and command quoting so Hermes can send Python code to your live Houdini session. + +## Quick Start + +### 1. Install the Shelf Tool (once) + +Copy `houdini/shelf/hermes_bridge.shelf` to your Houdini toolbar, or run the shelf script directly in Houdini's Python Shell. + +### 2. Open the Bridge (every session) + +Click the **Hermes Bridge** shelf button in Houdini. It opens port 12345 and shows a status dialog. + +### 3. Use from Hermes + +```bash +# Get selected node info +./hermes/bin/hhb get-selected + +# Set a parameter +./hermes/bin/hhb eval "hou.selectedNodes()[0].parm('file').set('/path/to/file.abc')" + +# Query connections +./hermes/bin/hhb eval "print(hou.selectedNodes()[0].inputConnections())" + +# Execute a complex Python script +./hermes/bin/hhb script ./examples/create_pyro_setup.py +``` + +## Requirements + +- Houdini 20+ (tested with 21.0) +- SSH access from Hermes host to Houdini workstation +- Network connectivity (port 12345, default) + +## Project Structure + +``` +hermes-houdini-bridge/ +├── houdini/ +│ └── shelf/ +│ └── hermes_bridge.shelf # Shelf tool: open/close port +├── hermes/ +│ ├── bin/ +│ │ └── hhb # Client: send commands to Houdini +│ └── skill/ +│ └── SKILL.md # Hermes skill for standard workflows +├── examples/ +│ └── create_pyro_setup.py # Example: build a pyro sim +├── docs/ +│ └── REFERENCE.md # hou module cheat sheet +└── README.md +``` + +## Security + +- Port 12345 opens on `0.0.0.0` — only use on trusted networks +- To restrict to localhost: modify the shelf tool's `openport` call to `hou.hscript('openport 12345 +.+.+.+')` +- The bridge uses Hermes' existing SSH key — no additional credentials needed + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| `hcommand: connect failed` | Port not open. Click shelf button in Houdini. | +| `hcommand: connection refused` | Houdini not running or port blocked by firewall. | +| Python error in output | Code has syntax issues. Test in Houdini Python Shell first. | +| `ModuleNotFoundError: No module named 'hou'` | Running hython directly, not through hcommand. Use `hhb`. | diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md new file mode 100644 index 0000000..39fb7f7 --- /dev/null +++ b/docs/REFERENCE.md @@ -0,0 +1,111 @@ +# hou Module Quick Reference + +Common patterns for the Hermes Bridge. + +## Selection & Navigation + +```python +# Selected nodes +hou.selectedNodes() # → list[hou.Node] +hou.selectedNodes()[0].path() # → '/obj/geo1/box1' +hou.selectedNodes()[0].type().name() # → 'box' + +# Find nodes +hou.node('/obj/geo1') # Get by path +hou.node('/obj').children() # All children +hou.node('/obj').recursiveGlob('*box*')# Glob search + +# Parent/child +node.parent() # Parent node +node.node('childname') # Named child +``` + +## Parameters + +```python +n = hou.selectedNodes()[0] + +# Get/Set +n.parm('tx').eval() # Evaluate value +n.parm('tx').set(5.0) # Set value +n.parm('file').set('/path/to/file') # Set string param + +# All non-default parms +[(p.name(), p.eval()) for p in n.parms() if not p.isAtDefault()] + +# Existence check +if n.parm('snippet') is not None: + n.parm('snippet').set('@P.y += 1;') +``` + +## Connections + +```python +# Inputs +n.inputConnections() # → tuple of (input_node, output_idx, input_idx) +n.inputConnectors() # Input connector names +n.setInput(0, other_node) # Connect + +# Outputs +n.outputConnections() # What connects FROM this node + +# Disconnect all inputs +for i in range(len(n.inputConnectors())): + n.setInput(i, None) +``` + +## Node Creation + +```python +parent = hou.node('/obj/geo1') + +# Single nodes +box = parent.createNode('box') +null = parent.createNode('null', 'OUT') + +# Common types +'geo' # Geometry container +'null' # Null +'attribwrangle' # Attribute Wrangle +'attribvop' # Attribute VOP +'file' # File read (SOP) +'rop_geometry' # Geometry ROP +'switch' # Switch +'merge' # Merge +'transform' # Transform (xform) +'sphere' # Sphere +'grid' # Grid +'scatter' # Scatter +'boolean' # Boolean +'remesh' # Remesh +'polyextrude' # Poly Extrude +'principledshader::2.0' # Material +``` + +## VEX + +```python +wrangle = hou.selectedNodes()[0] +wrangle.parm('snippet').set(''' +// Your VEX code here +@P.y += sin(@Frame * 0.1) * 0.5; +@Cd = rand(@ptnum); +''') +``` + +## Layout + +```python +node.moveToGoodPosition() # Auto-layout one node +for n in parent.children(): + n.moveToGoodPosition() # Layout all children +``` + +## Scene Info + +```python +hou.hipFile.name() # Current .hip path +hou.hipFile.basename() # Just filename +hou.expandString('$HIP') # Scene directory +hou.hscript('ls /obj') # Raw hscript +``` diff --git a/examples/create_pyro_setup.py b/examples/create_pyro_setup.py new file mode 100644 index 0000000..a8d5450 --- /dev/null +++ b/examples/create_pyro_setup.py @@ -0,0 +1,40 @@ +""" +Example: Create a simple Pyro simulation setup in Houdini. +Run via: hhb script examples/create_pyro_setup.py +""" +import hou + +# Create geometry container +obj = hou.node('/obj') +geo = obj.createNode('geo', 'pyro_sim') + +# Source geometry (sphere) +sphere = geo.createNode('sphere', 'pyro_source') +sphere.parm('radx').set(0.5) +sphere.parm('rady').set(0.5) +sphere.parm('radz').set(0.5) + +# Transform to position it +xform = geo.createNode('xform', 'position') +xform.setInput(0, sphere) +xform.parm('ty').set(2) + +# Null for output +null_geo = geo.createNode('null', 'OUT_GEO') +null_geo.setInput(0, xform) + +# Create DOP network for the sim +dopnet = geo.createNode('dopnet', 'pyro_sim') +dopnet.setInput(0, null_geo) + +# Inside the DOP: we'd need to set up pyro solver, smoke object, etc. +# For now, just create the structure +dopnet.moveToGoodPosition() + +# Layout +sphere.moveToGoodPosition() +xform.moveToGoodPosition() +null_geo.moveToGoodPosition() + +print("Pyro setup skeleton created at:", geo.path()) +print("Nodes:", [n.name() for n in geo.children()]) diff --git a/hermes/bin/hhb b/hermes/bin/hhb new file mode 100755 index 0000000..36e2677 --- /dev/null +++ b/hermes/bin/hhb @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────── +# hhb — Hermes-Houdini Bridge Client +# Sends Python/hou commands to a running Houdini session +# via SSH + hcommand. +# ────────────────────────────────────────────────────────── +set -euo pipefail + +HOST="${HHB_HOST:-niklas@192.168.178.39}" +PORT="${HHB_PORT:-12345}" +HCOMMAND="${HHB_HCOMMAND:-/opt/hfs21.0/bin/hcommand}" +WORKSPACE="${HHB_WORKSPACE:-/opt/data/projects/hermes-houdini-bridge}" + +usage() { + cat <<'EOF' +Usage: hhb [args] + +Commands: + eval Execute Python code in the running Houdini session. + Example: hhb eval "print(hou.selectedNodes())" + + script 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-param Get value of a parameter on the first selected node. + set-param Set a parameter on the first selected node. + get-inputs Print input connections of the first selected node. + status Check if the bridge port is open. + help Show this help. + +EOF + exit 0 +} + +# ── Helpers ────────────────────────────────────────────── + +run_remote_python() { + # Write Python code to a temp file, execute via hcommand. + # This avoids quoting hell with complex Python snippets. + local python_code="$1" + local tmpfile="/tmp/hhb_$$.py" + + # Upload script + printf '%s' "$python_code" | ssh "$HOST" "cat > $tmpfile" + + # Execute via hscript's python command — reads file and execs + local output + output=$(ssh "$HOST" \ + "$HCOMMAND $PORT \"python exec(open('$tmpfile').read())\"" 2>&1) || { + local rc=$? + ssh "$HOST" "rm -f $tmpfile" 2>/dev/null + echo "hhb: hcommand failed (exit $rc)" >&2 + echo "$output" >&2 + return $rc + } + + # Cleanup + ssh "$HOST" "rm -f $tmpfile" 2>/dev/null + + # hcommand output has a prefix line like "-> foobar", strip it + echo "$output" | sed 's/^->\s*//' +} + +# ── Commands ────────────────────────────────────────────── + +cmd_status() { + local output + output=$(ssh "$HOST" "$HCOMMAND $PORT openport" 2>&1) || { + echo "Bridge INACTIVE — Houdini not reachable or port $PORT closed." + return 1 + } + echo "$output" | sed 's/^->\s*//' +} + +cmd_eval() { + [[ $# -ge 1 ]] || { echo "Usage: hhb eval "; exit 1; } + run_remote_python "$*" +} + +cmd_script() { + [[ $# -ge 1 ]] || { echo "Usage: hhb script "; exit 1; } + local script_path="$1" + [[ -f "$script_path" ]] || { echo "File not found: $script_path"; exit 1; } + + scp -q "$script_path" "$HOST:/tmp/hhb_script_$$.py" + local output + output=$(ssh "$HOST" \ + "$HCOMMAND $PORT \"python exec(open('/tmp/hhb_script_$$.py').read())\"" 2>&1) || { + local rc=$? + ssh "$HOST" "rm -f /tmp/hhb_script_$$.py" 2>/dev/null + echo "hhb: hcommand failed (exit $rc)" >&2 + echo "$output" >&2 + return $rc + } + ssh "$HOST" "rm -f /tmp/hhb_script_$$.py" 2>/dev/null + echo "$output" | sed 's/^->\s*//' +} + +cmd_get_selected() { + run_remote_python ' +import hou +nodes = hou.selectedNodes() +if not nodes: + print("No nodes selected.") +else: + for n in nodes: + print(f"{n.path():50s} [{n.type().name()}]") +' +} + +cmd_get_param() { + [[ $# -ge 1 ]] || { echo "Usage: hhb get-param "; exit 1; } + local parm="$1" + run_remote_python " +import hou +nodes = hou.selectedNodes() +if not nodes: + print('No nodes selected.') +else: + p = nodes[0].parm('$parm') + if p is None: + print(f'Parameter \"$parm\" not found on {nodes[0].path()}') + else: + print(f'{nodes[0].path()}.{p.name()} = {p.eval()}') +" +} + +cmd_set_param() { + [[ $# -ge 2 ]] || { echo "Usage: hhb set-param "; exit 1; } + local parm="$1" + local val="$2" + run_remote_python " +import hou +nodes = hou.selectedNodes() +if not nodes: + print('No nodes selected.') +else: + p = nodes[0].parm('$parm') + if p is None: + print(f'Parameter \"$parm\" not found on {nodes[0].path()}') + else: + p.set('$val') + print(f'{nodes[0].path()}.{p.name()} = {p.eval()} ✓') +" +} + +cmd_get_inputs() { + run_remote_python ' +import hou +nodes = hou.selectedNodes() +if not nodes: + print("No nodes selected.") +else: + for n in nodes: + inputs = n.inputConnectors() + print(f"\n{n.path()} [{n.type().name()}]") + for i, conn in enumerate(inputs): + in_nodes = n.inputConnections(i) if hasattr(n, "inputConnections") else [] + if in_nodes: + for c in in_nodes: + print(f" input {i} ← {c.inputNode().path()} [{c.inputNode().type().name()}]") + else: + print(f" input {i} ← (none)") +' +} + +# ── Main ───────────────────────────────────────────────── + +[[ $# -ge 1 ]] || usage + +CMD="$1" +shift || true + +case "$CMD" in + eval) cmd_eval "$@" ;; + script) cmd_script "$@" ;; + get-selected) cmd_get_selected ;; + get-param) cmd_get_param "$@" ;; + set-param) cmd_set_param "$@" ;; + get-inputs) cmd_get_inputs ;; + status) cmd_status ;; + help|--help|-h) usage ;; + *) + echo "Unknown command: $CMD" + usage + ;; +esac diff --git a/hermes/skill/SKILL.md b/hermes/skill/SKILL.md new file mode 100644 index 0000000..067df01 --- /dev/null +++ b/hermes/skill/SKILL.md @@ -0,0 +1,113 @@ +# Hermes Skill: Houdini Bridge + +Standard workflows to control a running Houdini session via the hermes-houdini-bridge. + +## Trigger + +When the user asks to: +- Modify node parameters in Houdini +- Build node setups/networks in Houdini +- Write VEX/Wrangle code in Houdini +- Query selected nodes, connections, or scene state +- Execute Python in a running Houdini session + +## Prerequisites + +1. User must have the Hermes Bridge shelf tool active in Houdini (port 12345 open) +2. SSH access to the Houdini workstation (default: `niklas@192.168.178.39`) + +If the bridge is not open, tell the user: "Open the Hermes Bridge shelf tool in Houdini first." + +## Client Location + +`/opt/data/projects/hermes-houdini-bridge/hermes/bin/hhb` + +Always use this full path — the script handles SSH and hcommand internally. + +## Common Workflows + +### 1. Query Selected Nodes + +```bash +/opt/data/projects/hermes-houdini-bridge/hermes/bin/hhb get-selected +``` + +### 2. Set a Parameter + +```bash +/opt/data/projects/hermes-houdini-bridge/hermes/bin/hhb set-param +``` +Example: `hhb set-param file /path/to/texture.exr` + +### 3. Get a Parameter Value + +```bash +/opt/data/projects/hermes-houdini-bridge/hermes/bin/hhb get-param +``` + +### 4. Execute Arbitrary Python + +```bash +/opt/data/projects/hermes-houdini-bridge/hermes/bin/hhb eval "" +``` + +The code runs in Houdini's Python environment with full `hou` module access. + +### 5. Check Connection + +```bash +/opt/data/projects/hermes-houdini-bridge/hermes/bin/hhb status +``` + +## Useful hou Module Patterns + +### VEX in a Wrangler +```python +node = hou.selectedNodes()[0] +node.parm('snippet').set('@P.y += sin(@Frame * 0.1);\n@Cd = rand(@ptnum);') +``` + +### Build a Node Chain +```python +obj = hou.node('/obj') +geo = obj.createNode('geo', 'my_setup') +null_in = geo.createNode('null', 'IN') +wrangle = geo.createNode('attribwrangle', 'compute') +null_out = geo.createNode('null', 'OUT') +wrangle.setInput(0, null_in) +null_out.setInput(0, wrangle) +wrangle.parm('snippet').set('@Cd = rand(@ptnum);') +wrangle.moveToGoodPosition() +null_out.moveToGoodPosition() +``` + +### Read Node Info +```python +n = hou.selectedNodes()[0] +info = { + 'path': n.path(), + 'type': n.type().name(), + 'parms': {p.name(): p.eval() for p in n.parms() if not p.isAtDefault()}, + 'inputs': [c.inputNode().path() for c in n.inputConnections()], + 'outputs': [c.outputNode().path() for c in n.outputConnections()], +} +print(info) +``` + +### Create a Material +```python +matnet = hou.node('/mat') +principled = matnet.createNode('principledshader::2.0', 'my_material') +principled.parm('basecolorr').set(1.0) +principled.parm('basecolorg').set(0.2) +principled.parm('basecolorb').set(0.1) +principled.parm('rough').set(0.3) +``` + +## Pitfalls + +- Complex Python with quotes/backticks: Use `hhb script file.py` instead of `hhb eval "..."` +- Parameter names are internal (e.g., `file`, not `File`) +- Node types: check with `node.type().name()` first +- hcommand timeouts: complex scripts may take time, especially if triggering cooks +- Selected node counts: always check `len(hou.selectedNodes())` before operating diff --git a/houdini/shelf/hermes_bridge.py b/houdini/shelf/hermes_bridge.py new file mode 100644 index 0000000..dcdfe0a --- /dev/null +++ b/houdini/shelf/hermes_bridge.py @@ -0,0 +1,87 @@ +""" +Hermes Bridge — Open a command port for remote control by Hermes Agent. + +Usage (in Houdini Python Shell or Shelf Tool): + exec(open('/path/to/hermes_bridge.py').read()) + +Or install as shelf tool via the .shelf XML file. +""" + +import hou + +PORT = 12345 +IP_MASK = "0.0.0.0" # Accept connections from anywhere. Use "+.+.+.+" for localhost only. + + +def get_bridge_status(): + """Check if the bridge port is open.""" + result = hou.hscript("openport")[0] + if f"Port {PORT} is open" in result: + return True, result.strip() + return False, result.strip() + + +def open_bridge(): + """Open the command port.""" + # Close first in case it's stuck + hou.hscript(f"closeport {PORT}") + result, _ = hou.hscript(f"openport {PORT} {IP_MASK}") + return result.strip() + + +def close_bridge(): + """Close the command port.""" + result, _ = hou.hscript(f"closeport {PORT}") + return result.strip() + + +def show_dialog(message): + """Show a non-blocking status dialog.""" + hou.ui.displayMessage( + message, + title="Hermes Bridge", + severity=hou.severityType.ImportantMessage, + ) + + +def main(): + is_open, status = get_bridge_status() + + if is_open: + # Bridge is already open — show status and offer close + choice = hou.ui.displayMessage( + f"Hermes Bridge is ACTIVE\n\nPort: {PORT}\nMask: {IP_MASK}\n\nClick 'Close' to shut down, or 'OK' to keep running.", + buttons=('OK', 'Close'), + default_choice=0, + close_choice=0, + title="Hermes Bridge", + severity=hou.severityType.Message, + ) + if choice == 1: + result = close_bridge() + hou.ui.displayMessage( + f"Hermes Bridge CLOSED\n\n{result}", + title="Hermes Bridge", + severity=hou.severityType.ImportantMessage, + ) + else: + # Bridge closed — offer to open + choice = hou.ui.displayMessage( + f"Hermes Bridge is INACTIVE\n\nPort {PORT} is not open.\n\nOpen it to allow Hermes remote control.", + buttons=('Open Bridge', 'Cancel'), + default_choice=0, + close_choice=1, + title="Hermes Bridge", + severity=hou.severityType.Warning, + ) + if choice == 0: + result = open_bridge() + hou.ui.displayMessage( + f"Hermes Bridge ACTIVE\n\n{result}\n\nHermes can now control Houdini via:\n hhb eval \"hou.selectedNodes()[0].path()\"", + title="Hermes Bridge", + severity=hou.severityType.ImportantMessage, + ) + + +if __name__ == "__main__": + main()