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"?> <?xml version="1.0" encoding="UTF-8"?>
<shelfDocument> <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"> <toolshelf name="hermes_bridge" label="Hermes Bridge">
<memberTool name="hermes_bridge_dialog"/> <memberTool name="hermes_bridge_dialog"/>
</toolshelf> </toolshelf>
<tool name="hermes_bridge_dialog" label="Hermes Bridge" icon="MISC_python"> <tool name="hermes_bridge_dialog" label="Hermes Bridge" icon="MISC_python">
<helpText><![CDATA[Open the Hermes Bridge control panel. <helpText><![CDATA[Open the Hermes Bridge control panel.
Manage port, open/close the bridge, copy connection info.]]></helpText> 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 from PySide6 import QtWidgets, QtCore, QtGui
import hou import hou
import socket import socket
@@ -15,26 +38,8 @@ import os
CONFIG_FILE = os.path.join(hou.homeHoudiniDirectory(), "hermes_bridge.json") CONFIG_FILE = os.path.join(hou.homeHoudiniDirectory(), "hermes_bridge.json")
DEFAULT_PORT = 12345 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 = ( STYLE_CONNECTED = (
"QPushButton {" "QPushButton {"
" background: #5c1a1a; color: #ff7777;" " background: #5c1a1a; color: #ff7777;"
@@ -48,24 +53,68 @@ STYLE_DISCONNECTED = (
" padding: 12px; font-size: 15px; font-weight: bold; }" " 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): class HermesBridgeWindow(QtWidgets.QWidget):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.cfg = load_config() 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.hostname = socket.gethostname()
self._build_ui() 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.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() self._refresh()
# ---------- UI ----------
def _build_ui(self): def _build_ui(self):
self.setWindowTitle("Hermes Bridge") self.setWindowTitle("Hermes Bridge")
self.setMinimumWidth(420) self.setMinimumWidth(440)
self.setStyleSheet(""" self.setStyleSheet("""
QWidget { background: #1e1e1e; color: #e0e0e0; font-size: 13px; } QWidget { background: #1e1e1e; color: #e0e0e0; font-size: 13px; }
QLabel#title { font-size: 16px; font-weight: bold; color: #fff; } QLabel#title { font-size: 16px; font-weight: bold; color: #fff; }
QLabel#hint { color: #888; font-size: 11px; }
QPushButton#btnCopy { background: #1565c0; color: #fff; QPushButton#btnCopy { background: #1565c0; color: #fff;
border: none; border-radius: 4px; padding: 8px 16px; font-weight: bold; } border: none; border-radius: 4px; padding: 8px 16px; font-weight: bold; }
QPushButton#btnCopy:hover { background: #1976d2; } QPushButton#btnCopy:hover { background: #1976d2; }
@@ -73,6 +122,7 @@ class HermesBridgeWindow(QtWidgets.QWidget):
padding: 5px 8px; border-radius: 3px; } padding: 5px 8px; border-radius: 3px; }
QLineEdit { background: #2d2d2d; border: 1px solid #555; color: #aaa; QLineEdit { background: #2d2d2d; border: 1px solid #555; color: #aaa;
padding: 5px 8px; border-radius: 3px; } padding: 5px 8px; border-radius: 3px; }
QCheckBox { spacing: 8px; }
""") """)
layout = QtWidgets.QVBoxLayout(self) layout = QtWidgets.QVBoxLayout(self)
@@ -92,13 +142,24 @@ class HermesBridgeWindow(QtWidgets.QWidget):
self.port_spin.setRange(1024, 65535) self.port_spin.setRange(1024, 65535)
self.port_spin.setValue(self.port) self.port_spin.setValue(self.port)
self.port_spin.setFixedWidth(100) self.port_spin.setFixedWidth(100)
self.port_spin.valueChanged.connect(self._on_port_changed)
port_row.addWidget(self.port_spin) port_row.addWidget(self.port_spin)
port_row.addStretch() port_row.addStretch()
layout.addLayout(port_row) 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 = QtWidgets.QPushButton()
self.btn_toggle.setObjectName("btnToggle")
layout.addWidget(self.btn_toggle) layout.addWidget(self.btn_toggle)
layout.addWidget(QtWidgets.QLabel("Tell Hermes:")) layout.addWidget(QtWidgets.QLabel("Tell Hermes:"))
@@ -108,16 +169,25 @@ class HermesBridgeWindow(QtWidgets.QWidget):
prompt_row.addWidget(self.prompt_field) prompt_row.addWidget(self.prompt_field)
self.btn_copy = QtWidgets.QPushButton("Copy") self.btn_copy = QtWidgets.QPushButton("Copy")
self.btn_copy.setObjectName("btnCopy") self.btn_copy.setObjectName("btnCopy")
self.btn_copy.clicked.connect(self._copy)
prompt_row.addWidget(self.btn_copy) prompt_row.addWidget(self.btn_copy)
layout.addLayout(prompt_row) layout.addLayout(prompt_row)
layout.addStretch() layout.addStretch()
# ---------- state / refresh ----------
def _refresh(self): 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: if is_open:
self.status_label.setText( self.status_label.setText(
f"\u25cf Connected \u2014 {self.hostname}:{self.port}") f"\u25cf Connected \u2014 {display_host}:{self.port}")
self.status_label.setStyleSheet( self.status_label.setStyleSheet(
"color: #4caf50; font-size: 14px; font-weight: bold;") "color: #4caf50; font-size: 14px; font-weight: bold;")
self.btn_toggle.setText("Close Bridge") self.btn_toggle.setText("Close Bridge")
@@ -128,46 +198,78 @@ class HermesBridgeWindow(QtWidgets.QWidget):
"color: #ff5555; font-size: 14px; font-weight: bold;") "color: #ff5555; font-size: 14px; font-weight: bold;")
self.btn_toggle.setText("Open Bridge") self.btn_toggle.setText("Open Bridge")
self.btn_toggle.setStyleSheet(STYLE_DISCONNECTED) 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( self.prompt_field.setText(
f"Hey Hermes, I have a Houdini session running " 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): 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: try:
if bridge_is_open(self.port): if bridge_is_open(self.port):
hou.hscript(f"closeport {self.port}") hou.hscript(f"closeport {self.port}")
else: else:
hou.hscript(f"closeport {self.port}") hou.hscript(f"closeport {self.port}") # clear any stale state
hou.hscript(f"openport {self.port} 0.0.0.0") # openport takes no positional host arg. IP restriction is
self._refresh() # 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: except Exception as e:
hou.ui.displayMessage( hou.ui.displayMessage(
f"Toggle failed: {e}", f"Toggle failed: {e}",
severity=hou.severityType.Error, severity=hou.severityType.Error,
title="Hermes Bridge") title="Hermes Bridge")
finally:
self._refresh()
def _on_port_changed(self, value): def _on_port_changed(self, value):
was_open = bridge_is_open(self.port) if bridge_is_open(self.port):
if was_open: # Don't silently move a live port out from under the user.
hou.hscript(f"closeport {self.port}") 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.port = value
self.cfg["port"] = value self.cfg["port"] = value
save_config(self.cfg) save_config(self.cfg)
if was_open: self._refresh()
hou.hscript(f"openport {self.port} 0.0.0.0")
def _on_network_toggled(self, checked):
self.expose_network = checked
self.cfg["expose_network"] = checked
save_config(self.cfg)
self._refresh() self._refresh()
def _copy(self): def _copy(self):
QtWidgets.QApplication.clipboard().setText( QtWidgets.QApplication.clipboard().setText(self.prompt_field.text())
self.prompt_field.text())
self.btn_copy.setText("\u2713 Copied!") self.btn_copy.setText("\u2713 Copied!")
QtCore.QTimer.singleShot(2000, QtCore.QTimer.singleShot(2000, lambda: self.btn_copy.setText("Copy"))
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() win = HermesBridgeWindow()
hou.session.hermes_bridge_win = win hou.session.hermes_bridge_win = win
win.show() win.show()]]></script>
]]></script>
</tool> </tool>
</shelfDocument> </shelfDocument>