feat: robust bridge panel — TCP socket status check, network exposure opt-in, deferred hscript, German UI

This commit is contained in:
Niklas
2026-07-12 16:51:51 +02:00
committed by Hermes
parent ff2b46d465
commit 7a609e79ed
+146 -44
View File
@@ -1,12 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<shelfDocument>
<!-- This file contains definitions of shelves, toolbars, and tools.
It should not be hand-edited when it is being used by the application.
Note, that two definitions of the same element are not allowed in
a single file. -->
<toolshelf name="hermes_bridge" label="Hermes Bridge">
<memberTool name="hermes_bridge_dialog"/>
</toolshelf>
<tool name="hermes_bridge_dialog" label="Hermes Bridge" icon="MISC_python">
<helpText><![CDATA[Open the Hermes Bridge control panel.
Manage port, open/close the bridge, copy connection info.]]></helpText>
<script scriptType="python"><![CDATA[
<script scriptType="python"><![CDATA["""
Hermes Bridge - Houdini panel to open/close an hscript command port so an
external AI agent ("Hermes") can connect to this Houdini session.
Design notes (why this version differs from the old one):
- Port status is checked with a real socket connect, not by parsing
hou.hscript() text output. Text parsing was fragile and caused
bridge_is_open() to silently misreport state.
- The toggle button's signal is connected BEFORE the first _refresh(),
so a failed status check can never leave the button dead with no
handler attached.
- Defaults to binding 127.0.0.1 (localhost-only). Binding 0.0.0.0 opens
an unauthenticated command port to the whole network - anyone who can
reach the port can run hscript/Python inside your Houdini session.
There's an explicit opt-in checkbox for network exposure instead of
making that the default.
"""
from PySide6 import QtWidgets, QtCore, QtGui
import hou
import socket
@@ -15,26 +38,8 @@ import os
CONFIG_FILE = os.path.join(hou.homeHoudiniDirectory(), "hermes_bridge.json")
DEFAULT_PORT = 12345
CONNECT_TIMEOUT = 0.25 # seconds, for the local socket probe
def load_config():
try:
with open(CONFIG_FILE) as f:
return json.load(f)
except Exception:
return {"port": DEFAULT_PORT}
def save_config(cfg):
with open(CONFIG_FILE, "w") as f:
json.dump(cfg, f, indent=2)
def bridge_is_open(port):
try:
out = hou.hscript("openport")[0]
except Exception:
return False
return f"Port {port} is open" in out
# ── Styles ──────────────────────────────────────────
STYLE_CONNECTED = (
"QPushButton {"
" background: #5c1a1a; color: #ff7777;"
@@ -48,24 +53,68 @@ STYLE_DISCONNECTED = (
" padding: 12px; font-size: 15px; font-weight: bold; }"
)
def load_config():
try:
with open(CONFIG_FILE) as f:
cfg = json.load(f)
except Exception:
cfg = {}
cfg.setdefault("port", DEFAULT_PORT)
cfg.setdefault("expose_network", False)
return cfg
def save_config(cfg):
try:
with open(CONFIG_FILE, "w") as f:
json.dump(cfg, f, indent=2)
except Exception as e:
hou.ui.setStatusMessage(f"Hermes Bridge: could not save config ({e})",
severity=hou.severityType.Warning)
def bridge_is_open(port):
"""Check via an actual TCP connect attempt to 127.0.0.1:port.
Works regardless of whether the listener is bound to 127.0.0.1 or
0.0.0.0, since both accept loopback connections."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(CONNECT_TIMEOUT)
return s.connect_ex(("127.0.0.1", port)) == 0
except OSError:
return False
class HermesBridgeWindow(QtWidgets.QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.cfg = load_config()
self.port = self.cfg.get("port", DEFAULT_PORT)
self.port = self.cfg["port"]
self.expose_network = self.cfg["expose_network"]
self.hostname = socket.gethostname()
self._build_ui()
# Connect signals FIRST — _refresh may throw but button must work
# Connect signals BEFORE the first refresh. If a status check
# ever throws, the button still has its handler attached.
self.btn_toggle.released.connect(self._toggle)
self.port_spin.valueChanged.connect(self._on_port_changed)
self.chk_network.toggled.connect(self._on_network_toggled)
self.btn_copy.clicked.connect(self._copy)
self._refresh()
# ---------- UI ----------
def _build_ui(self):
self.setWindowTitle("Hermes Bridge")
self.setMinimumWidth(420)
self.setMinimumWidth(440)
self.setStyleSheet("""
QWidget { background: #1e1e1e; color: #e0e0e0; font-size: 13px; }
QLabel#title { font-size: 16px; font-weight: bold; color: #fff; }
QLabel#hint { color: #888; font-size: 11px; }
QPushButton#btnCopy { background: #1565c0; color: #fff;
border: none; border-radius: 4px; padding: 8px 16px; font-weight: bold; }
QPushButton#btnCopy:hover { background: #1976d2; }
@@ -73,6 +122,7 @@ class HermesBridgeWindow(QtWidgets.QWidget):
padding: 5px 8px; border-radius: 3px; }
QLineEdit { background: #2d2d2d; border: 1px solid #555; color: #aaa;
padding: 5px 8px; border-radius: 3px; }
QCheckBox { spacing: 8px; }
""")
layout = QtWidgets.QVBoxLayout(self)
@@ -92,13 +142,24 @@ class HermesBridgeWindow(QtWidgets.QWidget):
self.port_spin.setRange(1024, 65535)
self.port_spin.setValue(self.port)
self.port_spin.setFixedWidth(100)
self.port_spin.valueChanged.connect(self._on_port_changed)
port_row.addWidget(self.port_spin)
port_row.addStretch()
layout.addLayout(port_row)
self.chk_network = QtWidgets.QCheckBox(
"Für andere Rechner im Netzwerk freigeben (bindet 0.0.0.0)")
self.chk_network.setChecked(self.expose_network)
layout.addWidget(self.chk_network)
hint = QtWidgets.QLabel(
"Achtung: Der Command-Port hat keine Authentifizierung. "
"Netzwerkfreigabe erlaubt jedem im Netz, Befehle in dieser "
"Houdini-Session auszuführen.")
hint.setObjectName("hint")
hint.setWordWrap(True)
layout.addWidget(hint)
self.btn_toggle = QtWidgets.QPushButton()
self.btn_toggle.setObjectName("btnToggle")
layout.addWidget(self.btn_toggle)
layout.addWidget(QtWidgets.QLabel("Tell Hermes:"))
@@ -108,16 +169,25 @@ class HermesBridgeWindow(QtWidgets.QWidget):
prompt_row.addWidget(self.prompt_field)
self.btn_copy = QtWidgets.QPushButton("Copy")
self.btn_copy.setObjectName("btnCopy")
self.btn_copy.clicked.connect(self._copy)
prompt_row.addWidget(self.btn_copy)
layout.addLayout(prompt_row)
layout.addStretch()
# ---------- state / refresh ----------
def _refresh(self):
is_open = bridge_is_open(self.port)
try:
is_open = bridge_is_open(self.port)
except Exception as e:
is_open = False
hou.ui.setStatusMessage(f"Hermes Bridge: status check failed ({e})",
severity=hou.severityType.Warning)
display_host = self.hostname if self.expose_network else "localhost"
if is_open:
self.status_label.setText(
f"\u25cf Connected \u2014 {self.hostname}:{self.port}")
f"\u25cf Connected \u2014 {display_host}:{self.port}")
self.status_label.setStyleSheet(
"color: #4caf50; font-size: 14px; font-weight: bold;")
self.btn_toggle.setText("Close Bridge")
@@ -128,46 +198,78 @@ class HermesBridgeWindow(QtWidgets.QWidget):
"color: #ff5555; font-size: 14px; font-weight: bold;")
self.btn_toggle.setText("Open Bridge")
self.btn_toggle.setStyleSheet(STYLE_DISCONNECTED)
# Can't change the bind scope of a port that's already listening
# without reopening it, so lock the controls while connected.
self.port_spin.setEnabled(not is_open)
self.chk_network.setEnabled(not is_open)
self.prompt_field.setText(
f"Hey Hermes, I have a Houdini session running "
f"on {self.hostname} (port {self.port}).")
f"on {display_host} (port {self.port}).")
# ---------- actions ----------
def _toggle(self):
# Defer to the next event loop cycle to avoid re-entrancy issues
# with hou.hscript() inside a Qt signal handler.
QtCore.QTimer.singleShot(0, self._execute_toggle)
def _execute_toggle(self):
try:
if bridge_is_open(self.port):
hou.hscript(f"closeport {self.port}")
else:
hou.hscript(f"closeport {self.port}")
hou.hscript(f"openport {self.port} 0.0.0.0")
self._refresh()
hou.hscript(f"closeport {self.port}") # clear any stale state
# openport takes no positional host arg. IP restriction is
# done via -i ipmask. Default mask "+.+.+.+" already means
# "local machine only", so we only need -i when opting in
# to network exposure.
if self.expose_network:
cmd = f"openport -i *.*.*.* {self.port}"
else:
cmd = f"openport {self.port}"
out, err = hou.hscript(cmd)
if err:
raise RuntimeError(err.strip())
except Exception as e:
hou.ui.displayMessage(
f"Toggle failed: {e}",
severity=hou.severityType.Error,
title="Hermes Bridge")
finally:
self._refresh()
def _on_port_changed(self, value):
was_open = bridge_is_open(self.port)
if was_open:
hou.hscript(f"closeport {self.port}")
if bridge_is_open(self.port):
# Don't silently move a live port out from under the user.
self.port_spin.blockSignals(True)
self.port_spin.setValue(self.port)
self.port_spin.blockSignals(False)
hou.ui.displayMessage(
"Close the bridge before changing the port.",
title="Hermes Bridge")
return
self.port = value
self.cfg["port"] = value
save_config(self.cfg)
if was_open:
hou.hscript(f"openport {self.port} 0.0.0.0")
self._refresh()
def _on_network_toggled(self, checked):
self.expose_network = checked
self.cfg["expose_network"] = checked
save_config(self.cfg)
self._refresh()
def _copy(self):
QtWidgets.QApplication.clipboard().setText(
self.prompt_field.text())
QtWidgets.QApplication.clipboard().setText(self.prompt_field.text())
self.btn_copy.setText("\u2713 Copied!")
QtCore.QTimer.singleShot(2000,
lambda: self.btn_copy.setText("Copy"))
QtCore.QTimer.singleShot(2000, lambda: self.btn_copy.setText("Copy"))
# ── Keep alive via hou.session ──
# Keep the window alive via hou.session so it isn't garbage-collected.
win = HermesBridgeWindow()
hou.session.hermes_bridge_win = win
win.show()
]]></script>
win.show()]]></script>
</tool>
</shelfDocument>