Cthulhu support updated to use d-bus.

This commit is contained in:
Storm Dragon
2026-01-04 12:22:38 -05:00
parent 17d2954eef
commit ab0023f8f5
+156 -32
View File
@@ -180,6 +180,137 @@ class OrcaRemoteController:
return False return False
class CthulhuRemoteController:
"""D-Bus interface for Cthulhu screen reader remote control using dasbus library"""
_instance = None
_availability_checked = False
_is_available = False
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if self._availability_checked:
return
self.service_name = "org.stormux.Cthulhu.Service"
self.main_path = "/org/stormux/Cthulhu/Service"
self.proxy = None
self.speech_proxy = None
self.available = self._test_availability()
CthulhuRemoteController._is_available = self.available
CthulhuRemoteController._availability_checked = True
def _call_with_timeout(self, func, timeout_seconds=2):
"""Execute a function with timeout using threading"""
result = [None]
exception = [None]
def wrapper():
try:
result[0] = func()
except Exception as e:
exception[0] = e
thread = threading.Thread(target=wrapper, daemon=True)
thread.start()
thread.join(timeout=timeout_seconds)
if thread.is_alive():
# Timeout occurred
return None
if exception[0]:
raise exception[0]
return result[0]
def _get_cthulhu_dbus_address(self):
"""Try to find Cthulhu's D-Bus session address from its process"""
try:
# Find Cthulhu process
result = subprocess.run(
["pgrep", "-x", "cthulhu"],
capture_output=True,
text=True,
timeout=1
)
if result.returncode == 0:
pid = result.stdout.strip().split('\n')[0]
# Read Cthulhu's environment
with open(f"/proc/{pid}/environ", 'r') as f:
environ = f.read()
for var in environ.split('\0'):
if var.startswith('DBUS_SESSION_BUS_ADDRESS='):
return var.split('=', 1)[1]
except Exception:
pass
return None
def _test_availability(self):
"""Test if Cthulhu remote controller is available"""
def test_connection():
from dasbus.connection import SessionMessageBus
# First try with current session bus
try:
bus = SessionMessageBus()
self.proxy = bus.get_proxy(self.service_name, self.main_path)
self.proxy.ListCommands()
self.speech_proxy = bus.get_proxy(
self.service_name,
f"{self.main_path}/SpeechAndVerbosityManager"
)
return True
except Exception:
# If that fails, try to find Cthulhu's D-Bus session
cthulhu_bus_address = self._get_cthulhu_dbus_address()
if cthulhu_bus_address:
# Temporarily set the environment variable
old_address = os.environ.get('DBUS_SESSION_BUS_ADDRESS')
try:
os.environ['DBUS_SESSION_BUS_ADDRESS'] = cthulhu_bus_address
bus = SessionMessageBus()
self.proxy = bus.get_proxy(self.service_name, self.main_path)
self.proxy.ListCommands()
self.speech_proxy = bus.get_proxy(
self.service_name,
f"{self.main_path}/SpeechAndVerbosityManager"
)
return True
finally:
# Restore original address
if old_address:
os.environ['DBUS_SESSION_BUS_ADDRESS'] = old_address
elif 'DBUS_SESSION_BUS_ADDRESS' in os.environ:
del os.environ['DBUS_SESSION_BUS_ADDRESS']
raise
try:
return self._call_with_timeout(test_connection, timeout_seconds=2) or False
except Exception:
return False
def present_message(self, message):
"""Present a message via Cthulhu speech/braille output"""
if not self.available or not self.proxy:
return False
try:
return self._call_with_timeout(lambda: self.proxy.PresentMessage(message), timeout_seconds=2) or False
except Exception:
return False
def interrupt_speech(self):
"""Interrupt current speech via SpeechAndVerbosityManager"""
if not self.available or not self.speech_proxy:
return False
try:
return self._call_with_timeout(lambda: self.speech_proxy.ExecuteCommand("InterruptSpeech", False), timeout_seconds=2) or False
except Exception:
return False
# Initialize speech provider based on platform # Initialize speech provider based on platform
if platform.system() == "Windows": if platform.system() == "Windows":
# Set up DLL paths for Windows # Set up DLL paths for Windows
@@ -213,11 +344,12 @@ else:
speechProvider = "orca_remote" speechProvider = "orca_remote"
orca = orca_remote orca = orca_remote
else: else:
# Fall back to Cthulhu # Fall back to Cthulhu remote controller
try: cthulhu_remote = CthulhuRemoteController()
output = subprocess.check_output(["pgrep", "cthulhu"]) if cthulhu_remote.available:
speechProvider = "cthulhu" speechProvider = "cthulhu_remote"
except (subprocess.CalledProcessError, FileNotFoundError): cthulhu = cthulhu_remote
else:
try: try:
import accessible_output2.outputs.auto import accessible_output2.outputs.auto
s = accessible_output2.outputs.auto.Auto() s = accessible_output2.outputs.auto.Auto()
@@ -348,23 +480,16 @@ class SpeechHandler:
orca.interrupt_speech() orca.interrupt_speech()
if not orca.present_message(text): if not orca.present_message(text):
print(f"Orca remote error - message not delivered", file=sys.stderr) print(f"Orca remote error - message not delivered", file=sys.stderr)
elif speechProvider == "cthulhu_remote":
# Try Cthulhu, interrupt current speech then present message
cthulhu.interrupt_speech()
if not cthulhu.present_message(text):
print(f"Cthulhu remote error - message not delivered", file=sys.stderr)
elif speechProvider == "speechd": elif speechProvider == "speechd":
spd.cancel() spd.cancel()
spd.speak(text) spd.speak(text)
elif speechProvider == "accessible_output2": elif speechProvider == "accessible_output2":
s.speak(text, interrupt=True) s.speak(text, interrupt=True)
else: # Cthulhu
try:
process = subprocess.Popen(
["socat", "-", "UNIX-CLIENT:/tmp/cthulhu.sock"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
process.communicate(input=text)
except Exception as e:
print(f"Cthulhu error: {e}", file=sys.stderr)
def process_line(self, line: str) -> Optional[str]: def process_line(self, line: str) -> Optional[str]:
"""Process a line of game output for speech""" """Process a line of game output for speech"""
@@ -2152,12 +2277,10 @@ class DoomLauncher(QMainWindow):
if orca_remote.available: if orca_remote.available:
available_providers.append("orca_remote") available_providers.append("orca_remote")
# Check Cthulhu # Check Cthulhu remote controller
try: cthulhu_remote = CthulhuRemoteController()
subprocess.check_output(["pgrep", "cthulhu"]) if cthulhu_remote.available:
available_providers.append("cthulhu") available_providers.append("cthulhu_remote")
except (subprocess.CalledProcessError, FileNotFoundError):
pass
# Check accessible_output2 # Check accessible_output2
try: try:
@@ -2176,7 +2299,7 @@ class DoomLauncher(QMainWindow):
provider_names = { provider_names = {
"accessible_output2": "Accessible Output2", "accessible_output2": "Accessible Output2",
"orca_remote": "Orca Remote", "orca_remote": "Orca Remote",
"cthulhu": "Cthulhu", "cthulhu_remote": "Cthulhu Remote",
"speechd": "Speech Dispatcher" "speechd": "Speech Dispatcher"
} }
@@ -2211,8 +2334,8 @@ class DoomLauncher(QMainWindow):
def set_speech_provider(self, provider: str): def set_speech_provider(self, provider: str):
"""Update the global speech provider""" """Update the global speech provider"""
global speechProvider, s, spd, orca global speechProvider, s, spd, orca, cthulhu
try: try:
if provider == "accessible_output2": if provider == "accessible_output2":
import accessible_output2.outputs.auto import accessible_output2.outputs.auto
@@ -2226,12 +2349,13 @@ class DoomLauncher(QMainWindow):
else: else:
QMessageBox.warning(self, "Error", "Orca Remote is not available") QMessageBox.warning(self, "Error", "Orca Remote is not available")
return return
elif provider == "cthulhu": elif provider == "cthulhu_remote":
try: cthulhu_remote = CthulhuRemoteController()
subprocess.check_output(["pgrep", "cthulhu"]) if cthulhu_remote.available:
speechProvider = "cthulhu" cthulhu = cthulhu_remote
except (subprocess.CalledProcessError, FileNotFoundError): speechProvider = "cthulhu_remote"
QMessageBox.warning(self, "Error", "Cthulhu is not running") else:
QMessageBox.warning(self, "Error", "Cthulhu Remote is not available")
return return
elif provider == "speechd": elif provider == "speechd":
import speechd import speechd