feat: robust bridge panel — TCP socket status check, network exposure opt-in, deferred hscript, German UI
This commit is contained in:
+146
-44
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user